Appearance
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_adminrole to internal users. - Add
instance_grantstable (subject → instance, mirrorscustomer_grants). - Add
InstanceScopeto 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
:idreturns 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 // PR3Helper 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:
- If user has any unscoped internal role (
platform_admin,infra_ops,finance_admin,compliance_admin,reader) and notqa_admin:InstanceScope = nil. - If user has
qa_admin: load assigned instance IDs frominstance_grants.InstanceScope = {InstanceIDs: [...], Strict: true, Source: "qa_admin"}. - PR1 still computes
Scopeforqa_admin(a QA always also has at least one customer grant — see §6 below). - PR2 focus-mode layering already runs on
Scope; it doesn't touchInstanceScope.
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):
| Repository | Customer scope | Instance scope |
|---|---|---|
customers/repository.go — List | customers.id | join via tenants.instance_id (only show customers with at least one in-scope tenant) |
instances/repository.go — List | join tenants.customer_id | instances.id |
tenants/repository.go — List | tenants.customer_id | tenants.instance_id |
users/repository.go — ListCustomerUsers | customer_users.customer_id | (no instance dim) |
migrations/repository.go — ListJobs | join tenants.customer_id | join tenants.instance_id |
discrepancies/repository.go — List | tenant → customer | tenant → instance |
| Billing repos | *.customer_id | (QA has no billing access) |
Audit log repo — List | audit_log.customer_id | audit_log.instance_id |
Email logs repo — List | email_logs.customer_id | email_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 /tenantsreturns only A-on-X tenants (excludes A-on-Y, B-on-X, B-on-Y).GET /instances/:Y/...→ 403.GET /tenants/:tidwhere tid lives on Y → 403.POST /customers/:A/tenantsbody specifyinginstance_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 family | QA | Reason |
|---|---|---|
/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/settings | ❌ | Customer-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-logsfiltered by scope
Helper: requireQACapableAction() middleware factory. Applied to billing, SSO, webhooks, service-accounts, settings routes — denies QA.
Sidebar — web/src/layout/ConsoleLayout.tsx
Add qa_admin row:
| Role | Visible Nav Items |
|---|---|
qa_admin | Dashboard (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
- Add
'qa_admin'tointernalRolesand 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
QAenvironment instances" button — convenience that under the hood writes oneinstance_grantsrow per matching instance. - "Remove all" — bulk delete.
Audit-logged on grant / revoke.
8. Audit log
| Action | Subject | Target |
|---|---|---|
internal.instance_scope.granted | granting platform_admin | (qa_admin_user_id, instance_id) |
internal.instance_scope.revoked | granting 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
| Layer | Test |
|---|---|
| DB | Migration up/down idempotency |
| Middleware | QA with both scopes blocks foreign customer, foreign instance, allowed intersection |
| Repository | List repos with instance scope return only in-scope rows |
| Repository | Tenant create rejects mismatched (customer, instance) pair |
| Integration | scope_drift_test.go — instance dimension |
| Frontend | permissions.test.ts — isQAScoped, sidebar filter |
| Frontend | Tenant-create form filters dropdowns to assigned instances |
| E2E manual | QA logs in, sees only QA-fleet instances + assigned customer tenants |
10. Implementation order
| Step | Layer | Description |
|---|---|---|
| 1 | DB | Migration 006_instance_grants.sql |
| 2 | Backend | internal/model/user.go — RoleQAAdmin |
| 3 | Backend | internal/app/request.go — InstanceScope type + RequestClaims |
| 4 | Backend | internal/auth/middleware.go — populate InstanceScope from grants |
| 5 | Backend | internal/auth/instance_scope.go — middleware (mirror of customer) |
| 6 | Backend | internal/db/scope.go — ApplyInstanceScope + tenant-join helper |
| 7 | Backend | Wire helper into list repos (Section 5 table) |
| 8 | Backend | Tenant create handler — both scope checks |
| 9 | Backend | requireQACapableAction() middleware on billing/SSO/etc routes |
| 10 | Backend | /me payload returns assignedInstanceIDs |
| 11 | Backend | Assignment endpoints /internal-users/:id/instance-scopes |
| 12 | Backend | Drift test extension |
| 13 | Frontend | permissions.ts — role + helpers |
| 14 | Frontend | Sidebar gating |
| 15 | Frontend | Assignment UI — instance tab |
| 16 | Frontend | Tenant-create form filtering |
| 17 | Docs | Update docs/roles-and-permissions.md |
11. Risk register
| Risk | Mitigation |
|---|---|
| New instance-keyed list endpoint added without scope filter | Drift test (Section 5) — must register endpoint |
| QA creates tenant on assigned instance for unassigned customer | Tenant create checks both scopes |
| QA loses instance-grant; existing tenant they created becomes invisible | Documented; scope is current, not historical |
| QA in scope intersection sees billing-related fields on usage rows | PR4 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 scope | Validate at grant time: must have ≥1 customer_grant before instance_grant assignment |

