Skip to content

PR 3 — QA Role + Instance Scope

This PR introduces a new internal role, qa_admin, plus the instance scope plumbing that layers on top of customer scope from PR1. A QA admin is a Console employee whose visibility is restricted to a specific set of assigned customers and a specific set of instances (typically the QA fleet). Within that intersection, they manage tenants and users — but not billing, not customer-side configuration (SSO, webhooks, settings), and not anything internal-infra.

Depends on PR 1 (account-manager-and-scope-foundation.md). Reuses customer_grants and the CustomerScope resolver. Adds a parallel instance_grants table and an InstanceScope claim that ANDs with CustomerScope at query time.


1. Goal

  • Add qa_admin role to internal users.
  • Add instance_grants table (subject → instance, mirrors customer_grants).
  • Add InstanceScope to request claims; resolver populates from grants.
  • Repository helper ApplyInstanceScope + drift test entries for every instance-keyed list query.
  • Tenant create / delete / migrate routes for QA enforce both scopes.
  • Hide unassigned instances from instance pickers + listing.
  • Out-of-scope :id returns 403 (strict, same as AM).

Non-goals

  • Per-environment allowlists ({QA, DEV} on user). Decided against — see Decision Log §6. Per-instance grants are the source of truth; bulk-grant by environment is a UI ergonomic on top.
  • Instance-level focus mode (out of scope; PR2 is customer-only).
  • Granting QA write access to billing or customer settings (use AM if needed).

2. Scope unit

Instance. Tables affected: instances, tenants (via tenants.instance_id), tenant_user_assignments (via tenant), jobs (via tenant), tenant_discrepancies (via tenant).

Two scopes apply simultaneously to a QA admin:

effective(qa) = customer_grants(qa) ∩ instance_grants(qa)

A QA cannot see a tenant on an unassigned instance even if its customer is in scope, and cannot see a tenant on an assigned instance if its customer is not in scope.


3. Schema

New migration: internal/db/migrations/006_instance_grants.sql

sql
-- Mirror of customer_grants for instance-axis scoping.
CREATE TABLE instance_grants (
  grantee_type TEXT NOT NULL DEFAULT 'user'
    CHECK (grantee_type IN ('user', 'group')),
  grantee_id   UUID NOT NULL,
  instance_id  UUID NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
  granted_by   UUID REFERENCES internal_users(id),
  granted_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (grantee_type, grantee_id, instance_id)
);

CREATE INDEX idx_instance_grants_grantee  ON instance_grants(grantee_type, grantee_id);
CREATE INDEX idx_instance_grants_instance ON instance_grants(instance_id);

Constants

Add to internal/model/user.go:

go
const RoleQAAdmin UserRole = "qa_admin"

Mirror in web/src/auth/permissions.ts and slot between account_manager and reader in the role priority list.


4. InstanceScope on the request

Extend internal/app/request.go:

go
type InstanceScope struct {
    InstanceIDs []uuid.UUID
    Strict      bool   // QA = true; same semantics as CustomerScope
    Source      string // "qa_admin" | future: "group_membership"
}

func (s *InstanceScope) Bounded() bool { ... }
func (s *InstanceScope) Allows(id uuid.UUID) bool { ... }

Add to RequestClaims:

go
Scope         *CustomerScope  // PR1
InstanceScope *InstanceScope  // PR3

Helper on RequestContext:

go
func (r *RequestContext) InstanceScope() *InstanceScope {
    return r.Claims.InstanceScope
}

Resolution — internal/auth/middleware.go

After PR1 sets Scope (customer scope), compute instance scope:

  1. If user has any unscoped internal role (platform_admin, infra_ops, finance_admin, compliance_admin, reader) and not qa_admin: InstanceScope = nil.
  2. If user has qa_admin: load assigned instance IDs from instance_grants. InstanceScope = {InstanceIDs: [...], Strict: true, Source: "qa_admin"}.
  3. PR1 still computes Scope for qa_admin (a QA always also has at least one customer grant — see §6 below).
  4. PR2 focus-mode layering already runs on Scope; it doesn't touch InstanceScope.

InstanceScopedMiddleware (new)

Mirror of CustomerScopedMiddleware. Applied to routes with :instanceId or :tenantId params; for :tenantId routes the middleware joins the tenant's instance_id and checks membership.

go
if rc.InstanceScope().Bounded() {
    if id := c.Param("instanceId"); id != "" {
        // Direct instance-keyed route.
        if !rc.InstanceScope().Allows(parse(id)) {
            c.AbortWithStatusJSON(403, gin.H{"error": "out of scope"})
            return
        }
    } else if tid := c.Param("tenantId"); tid != "" {
        // Tenant-keyed route — join to instance.
        instanceID := lookupTenantInstance(tid)
        if !rc.InstanceScope().Allows(instanceID) {
            c.AbortWithStatusJSON(403, gin.H{"error": "out of scope"})
            return
        }
    }
}

The customer scope check from PR1 still runs in parallel. Both must pass.


5. Repository helper + drift test extension

Add internal/db/scope.go:

go
// ApplyInstanceScope — mirror of ApplyCustomerScope.
func ApplyInstanceScope(sql string, args []any, col string, scope *app.InstanceScope) (string, []any) { ... }

// ApplyTenantScopeViaInstance — when the table joins through tenants.instance_id.
func ApplyTenantScopeViaInstance(sql string, args []any, tenantCol string, scope *app.InstanceScope) (string, []any) { ... }

Where it must be called

Every list repo from PR1 §5 that has an instance dimension must AND in the instance scope. Updated table (additions in bold):

RepositoryCustomer scopeInstance scope
customers/repository.goListcustomers.idjoin via tenants.instance_id (only show customers with at least one in-scope tenant)
instances/repository.goListjoin tenants.customer_idinstances.id
tenants/repository.goListtenants.customer_idtenants.instance_id
users/repository.goListCustomerUserscustomer_users.customer_id(no instance dim)
migrations/repository.goListJobsjoin tenants.customer_idjoin tenants.instance_id
discrepancies/repository.goListtenant → customertenant → instance
Billing repos*.customer_id(QA has no billing access)
Audit log repo — Listaudit_log.customer_idaudit_log.instance_id
Email logs repo — Listemail_logs.customer_idemail_logs.instance_id

Drift test extension

internal/handler/scope_drift_test.go (created in PR1) extends with a second seed: 2 instances (X = QA-fleet, Y = PROD), 1 QA admin scoped to {customer A} × {instance X}.

Asserts:

  • GET /tenants returns only A-on-X tenants (excludes A-on-Y, B-on-X, B-on-Y).
  • GET /instances/:Y/... → 403.
  • GET /tenants/:tid where tid lives on Y → 403.
  • POST /customers/:A/tenants body specifying instance_id = Y → 403.

Adding a new instance-keyed endpoint without registering in this test is the failure mode this guards.


6. Route gating

Tenant create — both scopes

Tenant creation is a platform-side action (per docs/0 §2.1). QA can create tenants — but only when:

  • Target customer ∈ QA's customer_grants
  • Target instance ∈ QA's instance_grants

Implementation in internal/handler/tenants/handler.go Create:

go
if !rc.Scope().Allows(req.CustomerID) {
    return 403
}
if !rc.InstanceScope().Allows(req.InstanceID) {
    return 403
}

QA's allowed action set (narrower than AM)

Unlike AM, QA does not reach:

Route familyQAReason
/customers/:id/billing/*No billing per use case [4]
/customers/:id/auth-config/*SSO is customer-side concern
/customers/:id/webhooks/*Customer-owned
/customers/:id/service-accounts/*Customer-owned
/customers/:id/settingsCustomer-owned
All routes AM is denied (internal infra)Same denials

QA's allowed routes:

  • /customers/:id (read)
  • /customers/:id/users/* (manage users in scope)
  • /customers/:id/tenants/* (create / delete / manage in scope)
  • /instances/:id (read, only assigned)
  • /migrations/* for jobs in scope
  • /audit-log, /email-logs filtered by scope

Helper: requireQACapableAction() middleware factory. Applied to billing, SSO, webhooks, service-accounts, settings routes — denies QA.

Add qa_admin row:

RoleVisible Nav Items
qa_adminDashboard (scoped), Customers (scoped), Tenants (scoped), Instances (scoped), Users (scoped), Migrations (scoped), Audit Log (scoped), Email Logs (scoped), Changelog, Account

Hidden for QA: Internal Users, System Workers, Plans, Meters, Releases, Environment, User Attributes, Discrepancies (system), Billing (entire group), Customer Settings sub-pages.


7. Frontend

Types & permission map

web/src/auth/permissions.ts:

  • Add 'qa_admin' to internalRoles and the role priority list.
  • New helper:
    ts
    export function isQAScoped(roles?: PortalRole[]) {
      return roles?.includes('qa_admin') &&
             !roles.some(r => unscopedInternalRoles.includes(r))
    }
  • canPerform: QA bypasses portal action checks for tenant/user actions (network enforces scope), but is denied billing/SSO/webhooks/settings actions explicitly.

Auth store

[web/src/auth/store.ts] add assignedInstanceIDs?: string[] (parallel to assignedCustomerIDs from PR1), populated from /me.

/me payload

GetMe extends:

json
{
  "user": { ... },
  "scope": {
    "customerIds": ["..."],
    "instanceIds": ["..."],
    "source": "qa_admin"
  }
}

Instance picker pre-filter

Any picker showing instances (tenant create form, instance switcher, search palette) reads assignedInstanceIDs and filters client-side as a UX hint. Server-side scope filter is the security boundary.

Tenant create form

For QA: instance dropdown shows only assigned instances. Customer dropdown shows only assigned customers. Cross-product — picker can be a single two-step ("Customer → Instance") wizard.

Assignment management UI

Extend the page from PR1 §7 (/internal-users/:id/customers) to a tabbed view:

  • Tab 1: Assigned customers (PR1)
  • Tab 2: Assigned instances (PR3)

Each tab is a multi-select picker. Bulk-grant ergonomics:

  • "Add all QA environment instances" button — convenience that under the hood writes one instance_grants row per matching instance.
  • "Remove all" — bulk delete.

Audit-logged on grant / revoke.


8. Audit log

ActionSubjectTarget
internal.instance_scope.grantedgranting platform_admin(qa_admin_user_id, instance_id)
internal.instance_scope.revokedgranting platform_admin(qa_admin_user_id, instance_id)

Same noise discipline as PR1 — 403s from out-of-scope GETs go to HTTP access log, not audit log.


9. Tests

LayerTest
DBMigration up/down idempotency
MiddlewareQA with both scopes blocks foreign customer, foreign instance, allowed intersection
RepositoryList repos with instance scope return only in-scope rows
RepositoryTenant create rejects mismatched (customer, instance) pair
Integrationscope_drift_test.go — instance dimension
Frontendpermissions.test.tsisQAScoped, sidebar filter
FrontendTenant-create form filters dropdowns to assigned instances
E2E manualQA logs in, sees only QA-fleet instances + assigned customer tenants

10. Implementation order

StepLayerDescription
1DBMigration 006_instance_grants.sql
2Backendinternal/model/user.goRoleQAAdmin
3Backendinternal/app/request.goInstanceScope type + RequestClaims
4Backendinternal/auth/middleware.go — populate InstanceScope from grants
5Backendinternal/auth/instance_scope.go — middleware (mirror of customer)
6Backendinternal/db/scope.goApplyInstanceScope + tenant-join helper
7BackendWire helper into list repos (Section 5 table)
8BackendTenant create handler — both scope checks
9BackendrequireQACapableAction() middleware on billing/SSO/etc routes
10Backend/me payload returns assignedInstanceIDs
11BackendAssignment endpoints /internal-users/:id/instance-scopes
12BackendDrift test extension
13Frontendpermissions.ts — role + helpers
14FrontendSidebar gating
15FrontendAssignment UI — instance tab
16FrontendTenant-create form filtering
17DocsUpdate docs/roles-and-permissions.md

11. Risk register

RiskMitigation
New instance-keyed list endpoint added without scope filterDrift test (Section 5) — must register endpoint
QA creates tenant on assigned instance for unassigned customerTenant create checks both scopes
QA loses instance-grant; existing tenant they created becomes invisibleDocumented; scope is current, not historical
QA in scope intersection sees billing-related fields on usage rowsPR4 field redaction handles view_billed_units denial
Bulk-grant by environment grants too much (env has 50 instances when QA wanted 5)UI confirms count before commit; per-instance remove as undo
QA without any customer_grant has empty effective scopeValidate at grant time: must have ≥1 customer_grant before instance_grant assignment