Skip to content

Develop: v0.1.6#42

Merged
0xmanhnv merged 45 commits into
mainfrom
develop
Apr 14, 2026
Merged

Develop: v0.1.6#42
0xmanhnv merged 45 commits into
mainfrom
develop

Conversation

@0xmanhnv
Copy link
Copy Markdown
Collaborator

No description provided.

0xmanhnv added 30 commits April 9, 2026 08:40
Self-registration silently ignored a tenant's EmailVerificationMode=
"never" setting because the rule was only consulted when the user
already had a tenant context — and a brand-new user has none yet. The
admin sets "never" on the only tenant in their deployment, a new user
self-registers, and the system still demands email verification because
the resolution function defaulted to the platform-level SMTP / env
config.

Two fixes:

1. Single-tenant fallback in shouldRequireEmailVerification. When the
   caller has no tenant context AND the platform has exactly one
   active tenant, treat that tenant as the obvious target and apply
   its EmailVerificationMode. This covers the typical OSS deployment
   (one company = one tenant) without any new configuration. With
   more than one tenant the heuristic can't pick the "right" one and
   we fall back to the platform default, same as before.

2. invitation_token pass-through on /auth/register. RegisterInput
   gains an optional InvitationToken field; when present the register
   flow looks up the invitation, resolves the inviting tenant, and
   uses THAT tenant's rule. This handles the multi-tenant case for
   the invitation-link flow specifically — the invitation is the
   ground truth for which tenant the new user is going to land in.
   Failure to look up the token is non-fatal (we fall back to the
   single-tenant heuristic / platform default) so a stale or invalid
   token does not break registration; the token is validated for real
   later when the user POSTs to /invitations/{token}/accept.

Includes 4 new unit subtests on TestAuthService_Register:
  - single tenant with mode=never overrides global verification
  - single tenant with mode=always forces verification
  - multiple tenants falls back to global config
  - invitation token resolves to inviting tenant rule
A multi-role audit of the member-suspension feature surfaced two
critical security gaps and several performance hot paths. This commit
addresses the highest-impact items.

SECURITY

S1. Suspended members could still hit JWT-claim-scoped routes
    (/api/v1/me/*, /api/v1/notifications, /api/v1/api-keys, scanning,
    access_control, ...) until their access token expired. The
    URL-path tenant routes were already protected by RequireMembership
    in tenant.go but the JWT-tenant routes built via
    buildTokenTenantMiddlewares only checked tenant presence, not
    membership status. Add a new middleware
    RequireActiveMembershipFromJWT and append it automatically inside
    buildTokenTenantMiddlewares so every token-scoped route inherits
    the check. The middleware reads tenant id from JWT claims (not
    URL path), looks up the membership, and rejects suspended ones.

S2. ReactivateMember did not invalidate the permission cache, so a
    reactivated user could see stale "empty permissions" for up to
    5 minutes (the SuspendMember invalidation is what put them
    there). Mirror SuspendMember by calling invalidateUserPermissions.

S3. SuspendMember did not revoke sessions / refresh tokens. The
    permission cache was cleared and the URL-path middleware caught
    the user, but their existing JWT was still valid for non-tenant
    routes — and now that S1 closes that gap, S3 makes the closure
    immediate (a 30-min JWT lifetime would otherwise leave the gap
    open for half an hour). Wire SessionService into TenantService
    via SetSessionService and call RevokeAllSessions on suspend.

    While here, delete the dead Services.SetEmailEnqueuer helper that
    silently rebuilt s.Tenant with nil repo + nil log — it was never
    called from anywhere but would panic on first use if it was.
    Re-attach SetSessionService in main.go where the tenant service
    is reconstructed with the email enqueuer.

S4. Add composite indexes on tenant_members:
      - (user_id, status)   for GetUserMemberships filter
      - (tenant_id, status) for GetMemberStats counts
    Migration 000110 — both partial-index-friendly with the data we
    already have. The existing UNIQUE (user_id, tenant_id) constraint
    already covers GetMembership lookups.

PERFORMANCE

P2. Login was issuing two sequential queries to tenant_members for
    opposite status filters: GetUserMemberships (active) +
    GetUserSuspendedMemberships (the rest). Add
    GetUserMembershipsWithStatus that returns BOTH partitioned by
    status in a single round trip, and use it from Login. The
    legacy methods stay for now — they're still used by other call
    sites (RefreshToken, AcceptInvitationWithRefreshToken, etc.).

TESTS

- New middleware test cases for RequireActiveMembershipFromJWT
  (active allowed / suspended rejected / no tenant claim 401).
- Existing TenantService and AuthService tests still pass.
GET /api/v1/tenants/{tenant}/members?include=user,roles previously
issued one DB query to fetch members and then ONE QUERY PER MEMBER
to fetch their roles. A tenant with 100 members made 101 queries to
render a single page of the users table.

Replace the per-member loop in enrichMembersWithRoles with one batch
query:

  - new role.Repository.GetUsersRoles(tenantID, []userIDs)
    fetches all user→role assignments matching the given user IDs in
    a single SELECT joined with the roles table. Permissions for the
    matched roles are still loaded via the existing batch loader, so
    the total cost is at most 2 round trips regardless of member count
  - new RoleService.GetUsersRoles wrapper that parses the IDs and
    delegates to the repo
  - tenant_handler.enrichMembersWithRoles calls GetUsersRoles once,
    indexes the result by user_id, and attaches the roles to each
    MemberWithUserResponse in a tight loop
  - mockRoleRepo gains the new method so the unit suite still
    builds and runs
ListAssetOwnersWithNames, GetPrimaryOwnerBrief, and
GetPrimaryOwnersByAssetIDs all guarded the result set with this
predicate:

  AND (ao.user_id IS NULL OR ao.user_id IN
       (SELECT id FROM tenant_members WHERE tenant_id = $2))

The intent was "only return user owners who are members of this
tenant", but `tenant_members.id` is the MEMBERSHIP row id, not the
user id — they are two completely different UUIDs. The IN clause
therefore NEVER matched, so every user-owner row was filtered out
regardless of whether the user was actually a tenant member.

Symptom (reported by an operator): the unified picker added a user
to an asset successfully (POST returned 200, the asset_owners row is
in the database), but the asset detail Owners tab kept showing
"Owners (0) — No owners assigned" because the GET endpoint dropped
the row at the SQL level.

Fix: change the subquery to `SELECT user_id FROM tenant_members ...`
in all three call sites. Group owners were unaffected because their
filter uses the groups.tenant_id column directly.

No schema change. The bug pre-dates the unified-picker UI work but
was masked by everyone happening to assign group owners only.
GetMemberStats was issuing three sequential queries against the same
table set: one for total/active counts, one for role counts via the
v_user_effective_role view, and one for pending invitations. Every
admin dashboard render hit the DB three times for a single stat
card.

Replace with a single query using a CTE that materialises the member
rows once and feeds COUNT(*) FILTER aggregates from the CTE plus a
correlated sub-SELECT for invitations. Same result shape, one third
the round trips.
The legacy branch of ListMembers handler called the unpaginated
ListMembersWithUserInfo when no search/limit/offset parameters were
present. A tenant with thousands of members would load the entire
table into memory and serialise it to JSON in one shot, which is
both a memory hazard and a slow request.

Always route through SearchMembersWithUserInfo with a default limit
of 100. Clients that need more rows can opt in via ?limit=N up to a
hard cap of 500. The unpaginated path is gone from the request flow
(the repository method stays for now to avoid touching the
interface, but no handler invokes it).
… DB hit)

Every authenticated tenant-scoped request — and every JWT-claim
tenant request after the S1 fix — went through the membership status
check, which translated to one database SELECT against tenant_members
per HTTP call. A typical dashboard load fires 30-50 API calls in a
burst, so the middleware was hammering the database 30-50 times for
exactly the same (user, tenant) lookup. With composite indexes the
queries are fast (single-row by index) but the round trips dominate.

Replace the direct repo lookup with a Redis-backed wrapper:

- new internal/app/membership_cache_service.go: thin
  MembershipCacheService that satisfies a slim
  middleware.MembershipReader interface. Stores a small
  CachedMembership DTO (id, role, status, joinedAt) with a 5-minute
  TTL. On hit it reconstructs the entity via
  ReconstituteMembershipWithStatus so middleware downstream code
  reads the same shape it always did. On miss it falls through to
  tenant.Repository and writes the cache. Cache failures are logged
  and never fatal — correctness comes from the repo, the cache is
  pure performance.

- middleware refactor: RequireMembership and
  RequireActiveMembershipFromJWT now take a MembershipReader instead
  of tenant.Repository. The repo still satisfies the interface so
  call sites without a cache continue to work; the wiring code
  prefers the cache when available and falls back to the repo when
  it isn't.

- TenantService gains SetMembershipCache + invalidateMembershipCache
  helper. Every mutation that could shift role or status drops the
  cached entry immediately so the next request sees fresh state
  without waiting for the TTL:
    - SuspendMember (next request → 403)
    - ReactivateMember (next request → permitted)
    - UpdateMemberRole (next request → new role)
    - RemoveMember (next request → 403 not-a-member)

- services.go wires NewMembershipCacheService alongside the existing
  permission cache and threads it through routes.Register down to
  the middleware constructors. main.go's routes.Register call passes
  services.MembershipCache.

Net effect: a 50-call dashboard burst goes from 50 indexed DB SELECTs
to 1 SELECT (cold cache, first call) + 49 Redis GETs. Suspension still
takes effect on the very next request because the invalidation runs
synchronously inside SuspendMember.

No schema changes, no new tests required for the legacy paths (the
middleware unit tests still pass against the in-memory mock repo
which trivially satisfies the new interface).
… trail

- Campaign team roles (lead/tester/reviewer/observer) with 5-layer authorization
- Finding CRUD with pentest-specific metadata (steps, PoC, impact, remediation)
- Status workflow with role-based transitions (tester passed != auto-verified)
- Attachment system: upload/download/delete with campaign membership check
- File dedup (SHA-256 per finding), per-tenant storage (Local/S3/MinIO)
- S3 credentials encrypted via AES-256-GCM, storage_provider tracked per file
- Activity trail logging for create/update/status/retest operations
- Refresh token cleanup (used/revoked with 1h grace for replay detection)
- Performance indexes: severity sort, campaign stats, activities, attachments
- Security: SVG blocked, content-type sniffing, nosniff header, body size limit
- Unit tests: 69 tests (pentest service + attachment service)
- E2E tests: 2 bash scripts with 114 assertions
- GET /campaigns/{id}/findings/export — CSV download with all finding fields
- Supports ?format=json for JSON export alternative
- Includes: ID, Title, Severity, Status, CVSS, CWE, CVE, Affected Assets, Created
- Handler reads both flat metadata.poc_code and nested metadata.pentest.poc_code
- Merges nested fields into flat map for backward compatibility
- CommentSection: fetch + display existing comments with instant update
- Add Details button linking to /findings/{id} detail page
- CSV export includes all pentest fields: steps, PoC, impact, remediation, references
- UTF-8 BOM for Excel compatibility
- Migration 000119: finding_number column with backfill
- Legacy nested metadata merge for seed data
- FindingImportService: parse Burp Suite XML and generic CSV formats
- Auto-detect format from file extension (.xml → burp, .csv → csv)
- Burp: maps severity, strips HTML, extracts request/response pairs
- CSV: flexible column mapping (title, severity, description, etc)
- POST /campaigns/{id}/findings/import (lead only)
- Migration 000119: finding_number column for human-readable IDs
… backend

- HTML report generator with Go templates (pkg/report/)
- Campaign stats extended: status breakdown, CVSS aggregates
- Campaign metadata JSONB for OWASP checklist persistence
- Report download endpoint: GET /campaigns/{id}/reports/download
- Auto-remediation workflow templates (configs/workflow-templates/)
- Asynq task for scheduled report generation
- Input validation: watermark sanitization, classification whitelist
- Migrations 000120-000122: simulations, threat actors, report schedules, performance indexes
- Domain models: simulation (entity, run, control test), threatactor, reportschedule
- Repositories: SimulationRepository, ThreatActorRepository with tenant isolation
- Services: SimulationService (nil-safe controlRepo), ThreatActorService
- Handlers + routes: /api/v1/simulations, /api/v1/control-tests, /api/v1/threat-actors
- MITRE ATT&CK mapping, BAS result ingestion, STIX/TAXII connector foundation
- Pagination bounds validation, rows.Err() checks, error wrapping
- Asset correlation: FindByIP/FindByHostname search across name + properties
- Auto-rename: IP-named hosts get renamed when hostname arrives from another source
- Freshness-aware merge: newer data wins during upsert, stale fills gaps only
- Host IP standardized: properties.ip (string) → ip_addresses (array)
- Migration 000123: indexes for IP/hostname correlation queries
- Migration 000124: data migration converting legacy ip to ip_addresses
- ControlTest Postgres repository with GetStatsByFramework
- Ingest processor: normalizeHostIPProperties for new host ingestion
- ArchiveStaleAssets: find and archive assets unseen > N days
- Asynq task for scheduled lifecycle cleanup (maintenance queue)
- Fixed tests for CreateAsset upsert behavior (GetByName instead of ExistsByName)
- Added IPCorrelation test case
- Asset detail: name uses break-words instead of truncate
- New file: pkg/domain/asset/category.go with TypeToCategory mapping
- Asset entity: Category() derived method (not stored in DB)
- API response: includes 'category' field for UI grouping
- Categories: external_surface, application, infrastructure, network, cloud, data, code, identity, other
- Migration 000125: remediation_campaigns table with priority, progress, risk tracking
- Domain model: Campaign entity with status lifecycle (draft→active→validating→completed)
- Repository: Postgres CRUD with priority-ordered listing
- Service: create, update status, progress tracking, risk reduction
- Handler: REST API for campaigns
- Routes: GET/POST /api/v1/remediation/campaigns, GET/PATCH/DELETE /{id}
- Risk tracking: before/after risk scores, reduction calculation
- Overdue detection: IsOverdue() based on due_date
- GetMTTRMetrics: average hours to remediate per severity
- GetRiskVelocity: new vs resolved per week (12-week window)
- RiskVelocityPoint type: positive = losing ground, negative = improving
- Ready for UI consumption via dashboard API
- GET /api/v1/dashboard/mttr — MTTR in hours by severity
- GET /api/v1/dashboard/velocity?weeks=12 — new vs resolved per week
- Service layer wraps repo queries
- Repository interface extended with GetMTTRMetrics + GetRiskVelocity
- Migration 000126: business_units + business_unit_assets tables, crown jewel columns on assets
- Domain model: BusinessUnit entity with risk aggregation stats
- Repository: CRUD + asset linking (many-to-many)
- Service: create, list, add/remove assets
- Handler + routes: GET/POST /api/v1/business-units, asset linking
- Crown jewels: is_crown_jewel, business_impact_score columns on assets table
- Fixed dashboard test mock for MTTR/velocity interface
…d trigger

- Workflow template #5: fix_applied → auto trigger verification scan + notify team
- Added TriggerTypeFindingStatusChanged for clearer intent
- Uses existing trigger_scan action type
- PATCH /api/v1/assets/{id}/crown-jewel — mark/unmark crown jewel with impact scoring
- SaveAsset() — direct entity persist for handlers that modify entity directly
- Route registered with AssetsWrite permission
- 5 network segments (PROD-LAN, DMZ, MGMT, DEV, BACKUP)
- 3 firewalls (Palo Alto perimeter, Fortinet internal, F5 WAF)
- 2 load balancers (F5 trading, HAProxy web)
- 3 switches (Cisco core + 2 access)
- 1 wireless AP (Cisco Catalyst)
- 1 WAN router (Juniper MX204)
- All with vendor, model, firmware, management IP, serial number
- properties: stores CIDR, VLAN, gateway for network segments
- group_type: manual, dynamic, network_segment, business_unit
- Enables network segments to be managed as asset groups instead of assets
- Migration 000128: sub_type column, backfill 21 types, consolidate to 15 core types
- Fix asset_type_categories: add external_surface + network, fix assignments
- TypeAliases map + ResolveTypeAlias() for ingest normalization
- Asset entity: SubType() getter, SetSubType() setter
- API response includes sub_type field
- Fully reversible: down migration restores from sub_type
0xmanhnv added 10 commits April 13, 2026 07:27
Bug fixes:
- CHECK constraint: add 'application', 'identity', 'kubernetes' to allowed values
- Repository SELECT: include sub_type column in query
- Repository INSERT/UPDATE: persist sub_type on create and update
- AllAssetTypes(): add 3 new consolidated types
- CategoryForType(): add application, identity, kubernetes mappings

Tests:
- TestResolveTypeAlias: 28 cases (21 aliases + 7 pass-through)
- TestParseAssetType_NewTypes: application, identity, kubernetes
- TestCategoryForType: 12 type→category mappings
- TestSubTypeOnEntity: getter/setter on entity

All unit tests pass (12.5s)
…sset_type

SAFE migration: only adds sub_type column and backfills from current type.
Does NOT change asset_type values. Zero risk of breaking UI/API.
Rollback: DROP COLUMN sub_type.
- Filter: is_crown_jewel=true for crown jewel assets
- Filter: sub_type=firewall for sub-type filtering
- nilIfEmpty() helper for optional string query params
- Crown jewel filter wired: Filter → Repository → Handler
- FetchEPSSScores: top 1000 CVEs from FIRST.org API
- FetchKEVCatalog: full CISA KEV catalog JSON
- FetchEPSSForCVEs: targeted EPSS lookup for specific CVEs
- Ready for Asynq job handler integration
…types)

Requires 000128 (sub_type backfill) and 000129 (legacy DB types).
Changes: firewall→network, website→application, iam_user→identity, etc.
Original type preserved in sub_type column for rollback.
- SQL: GROUP BY sub_type for assets with sub_type set
- AssetStatsData: added BySubType map
- DashboardStats: added AssetsBySubType
- API response: by_sub_type field in asset stats
GetAllStats (used by main dashboard endpoint) was missing asset_by_sub_type CTE.
Only GetAssetStats (simple query) had it. Now both return by_sub_type data.
…ub_type stats

- Add GET /assets/facets endpoint — returns distinct property keys + values
  for dynamic filtering UI (uses GIN index on properties JSONB)
- Add ?properties=key:value query param to GET /assets — server-side
  JSONB containment filter with parameterized queries (injection-safe)
- Add ?sub_type= param to GET /assets/stats — stats scoped to sub_type
- Add by_sub_type breakdown to /assets/stats response
- Add PropertiesFilter to domain Filter struct + repository WHERE clause
- Add GetPropertyFacets to Repository interface + Postgres implementation
- Migration 000131: normalize network sub_types (core_switch/access_switch → switch)
- Update test mocks for new interface methods
Comment thread internal/infra/postgres/business_unit_repository.go Fixed
Comment thread internal/infra/postgres/control_test_repository.go Fixed
Comment thread internal/infra/postgres/remediation_campaign_repository.go Fixed
Comment thread internal/infra/postgres/simulation_repository.go Fixed
Comment thread internal/infra/postgres/threat_actor_repository.go Fixed
Comment thread internal/infra/postgres/business_unit_repository.go Fixed
Comment thread internal/infra/postgres/control_test_repository.go Fixed
Comment thread internal/infra/postgres/remediation_campaign_repository.go Fixed
Comment thread internal/infra/postgres/simulation_repository.go Fixed
Comment thread internal/infra/postgres/threat_actor_repository.go Fixed
Collectors can now send known fields inside properties JSONB and the
system auto-promotes them to proper columns:
- sub_type → entity.SubType column
- type → resolved via TypeAliases (e.g., "firewall" → type=network)
- scope, exposure, description → override if empty
- tags → merged with input tags
- name, tenant_id, criticality, status, owner_ref → removed from JSONB

Also:
- Fix LIKE wildcard injection in business_unit search (escape % and _)
- Fix CodeQL warning: remove unused argIdx increment
- Add Properties field to CreateAssetInput + CreateAssetRequest
Unit tests (23 tests):
- TestPromoteKnownProperties: sub_type, type alias, scope/exposure,
  tags (array + string), column name removal, empty properties, description
- TestParsePropertiesFilter: single/multi, spaces, colon in value,
  empty key/value, max 5 pairs, injection chars, unicode, underscore

Integration test (18 assertions):
- Stats endpoint: total, by_type, by_sub_type, sub_type filter
- Facets endpoint: network facets, host facets
- Properties filter: vendor:Cisco, non-existent, multi-filter
- Promote on create: sub_type, scope, vendor preserved, type alias
- Identity type: list, sub_type filter
- Modules: asset sub-modules count, identity module exists
- Cleanup: deletes test assets after run

Also: export PromoteKnownProperties + ParsePropertiesFilter for testability
Codify module changes into a reversible migration:
- Deprecate: cloud_resources, kubernetes, serverless, ports, web_apps, compute
- Rename: containers → Containers & K8s, data_stores → Storage
- Upgrade: subdomains → released
- Add: assets.identity (Identity & Access)
- Clean orphan tenant_modules for deprecated modules
- Down migration: restore pre-consolidation state, clean FK before DELETE
@0xmanhnv 0xmanhnv merged commit 7c20648 into main Apr 14, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants