Appearance
PR 1 — Account Manager Role + Customer Scope Foundation
This PR introduces a new internal role, account_manager, plus the underlying request scope infrastructure that both this feature and the upcoming Customer Focus Mode (PR 2) depend on.
An account manager is a Console employee whose visibility is restricted to a specific set of assigned customers. AM holds the platform_admin action set on assigned customers — invite users, manage tenants, configure SSO/IdP, webhooks, service accounts, customer settings, full billing, run migrations on the customer's tenants. They cannot reach infra-level surfaces (DB URI, redirect URIs, releases, system workers, internal users, plans/meters catalog) and cannot perform the three platform-only floors: create customer, cross-customer tenant transfer, cross-customer user home-org transfer (see docs/0 §3.6). Other internal roles are unaffected.
1. Goal
- Add
account_managerrole to internal users. - Add a many-to-many
customer_grantstable (one user → many customers; polymorphicgrantee_typecolumn ready for groups in PR6). - Build a request-scoped
CustomerScopethat the repository layer reads when filtering list queries — used by AM today and Focus Mode in PR 2. - Enforce scope on every customer-owned read and write. Out-of-scope single resource fetches return HTTP 403 (strict mode).
- Hide internal-only pages from AMs except Changelog.
Non-goals
- Focus Mode (separate PR).
- Granular per-customer-per-action scoping (assignment is binary).
- Time-bound assignments / scheduled access (future).
2. Scope unit
Customer. Instance-level focus is explicitly out of scope. Every scoped table either has a customer_id column or joins to one through tenants / tenant_user_assignments.
3. Schema
New migration: internal/db/migrations/00X_customer_grants.sql (the next unused migration number — existing untracked 005/006 migrations may bump this).
sql
-- 1. Allow 'account_manager' as an internal role
-- (roles is TEXT[] with no DB-side check; constants live in internal/model/user.go)
-- 2. Customer grants — polymorphic subject (user today, group in PR6).
-- Named around the *resource* (customer), not the subject, so the family
-- extends cleanly: customer_grants, instance_grants (PR3), etc.
CREATE TABLE customer_grants (
grantee_type TEXT NOT NULL DEFAULT 'user'
CHECK (grantee_type IN ('user', 'group')),
grantee_id UUID NOT NULL,
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
granted_by UUID REFERENCES internal_users(id),
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (grantee_type, grantee_id, customer_id)
);
CREATE INDEX idx_customer_grants_grantee ON customer_grants(grantee_type, grantee_id);
CREATE INDEX idx_customer_grants_customer ON customer_grants(customer_id);PR1 only writes rows with grantee_type = 'user'. PR6 starts writing 'group' rows; the resolver extends with one more OR clause. No migration cost later — see docs/0 §5 for the full forward-compat rationale.
No data backfill needed — existing platform_admin / ops_engineer / finance_admin / compliance_admin / reader users do not receive any rows; they remain unscoped.
Constants
Add to internal/model/user.go:
go
const RoleAccountManager UserRole = "account_manager"Mirror in web/src/types and the role priority list in web/src/auth/permissions.ts:54 — slot between reader and owner.
4. Foundation — CustomerScope on the request
Extend internal/app/request.go:
go
// CustomerScope is the set of customer IDs a request is allowed to touch.
// Nil/empty = no scope (e.g. platform_admin, finance_admin); otherwise
// repository list queries MUST filter to these IDs.
type CustomerScope struct {
CustomerIDs []uuid.UUID
// Strict=true → out-of-scope GET on a single resource returns 403.
// Strict=false (Focus Mode in PR 2) → returns 404/empty so toggling off
// is friction-free. AM is always strict.
Strict bool
// Source documents how the scope was computed (audit + telemetry).
Source string // "account_manager" | "focus_mode" | "intersection"
}
func (s *CustomerScope) Bounded() bool { return s != nil && len(s.CustomerIDs) > 0 }
func (s *CustomerScope) Allows(id uuid.UUID) bool { ... }Add to RequestClaims:
go
Scope *CustomerScopeHelper on RequestContext:
go
func (r *RequestContext) Scope() *CustomerScope { return r.Claims.Scope }Resolution — internal/auth/middleware.go
After the existing RequestContextMiddleware populates roles, compute scope:
- If user has
platform_admin,ops_engineer,finance_admin,compliance_admin, orreader(any non-AM internal role): scope =nil. - If user has
account_managerand no higher unscoped role: load assigned customer IDs fromcustomer_grants WHERE grantee_type = 'user' AND grantee_id = $userID. Scope ={CustomerIDs: [...], Strict: true, Source: "account_manager"}. - If a user has both
account_managerand a non-AM role: the non-AM role wins (no scope). Document this so it isn't a footgun: when granting AM, do not co-grant other roles unless the team intends global access. - PR 2 will layer focus-mode narrowing here.
CustomerScopedMiddleware change
internal/auth/customer_scope.go currently short-circuits all internal users (line 20). Update:
go
if rc == nil { c.Next(); return }
// Internal users with no scope: bypass as today.
if rc.IsInternal() && !rc.Scope().Bounded() {
c.Next(); return
}
// Internal users with scope (account_manager): enforce membership when URL has :id.
if rc.IsInternal() && rc.Scope().Bounded() {
if id := c.Param("id"); id != "" {
oid, err := uuid.Parse(id)
if err != nil { /* 400 */ }
if !rc.Scope().Allows(oid) {
c.AbortWithStatusJSON(403, gin.H{"error": "out of scope"})
return
}
}
c.Next(); return
}
// Existing customer-portal-user logic unchanged below.5. Repository helper + drift test
A single helper that every list query calls. Without this, every new endpoint is a chance to silently leak data — same shape as the Job model drift problem called out in CLAUDE.md.
New file: internal/db/scope.go
go
package db
import (
"github.com/google/uuid"
"github.com/exto360-inc/console/internal/app"
)
// ApplyCustomerScope appends a "<col> = ANY($N)" clause and returns the
// updated SQL + args. If scope is nil/unbounded it returns the input
// unchanged. The caller is responsible for placing the clause in the right
// position relative to existing WHERE / AND tokens.
func ApplyCustomerScope(sql string, args []any, col string, scope *app.CustomerScope) (string, []any) {
if scope == nil || !scope.Bounded() { return sql, args }
args = append(args, customerIDStrings(scope.CustomerIDs))
sql += fmt.Sprintf(" AND %s = ANY($%d)", col, len(args))
return sql, args
}
// ApplyTenantScopeViaCustomer joins through tenants when the table does
// not have a customer_id column directly (e.g. tenant_user_assignments).
func ApplyTenantScopeViaCustomer(sql string, args []any, tenantCol string, scope *app.CustomerScope) (string, []any) { ... }Where it must be called
| Repository | Column / Join |
|---|---|
internal/handler/customers/repository.go — List | customers.id |
internal/handler/instances/repository.go — List | join tenants.customer_id |
internal/handler/tenants/repository.go — List | tenants.customer_id |
internal/handler/users/repository.go — ListCustomerUsers | customer_users.customer_id |
internal/handler/migrations/repository.go — ListJobs | join tenants.customer_id |
internal/handler/discrepancies/repository.go — List | tenant_discrepancies → tenant → customer |
| Billing repos — invoices, payments, credits, usage | *.customer_id |
Audit log repo — List | audit_log.customer_id |
Email logs repo — List | email_logs.customer_id |
Drift test
New: internal/handler/scope_drift_test.go
A single integration test that:
- Seeds 2 customers (A, B) + tenants/users/invoices on each.
- Creates an internal user with
account_managerrole + scope = {A}. - Iterates a hard-coded list of GET endpoints (table below) and asserts:
- Every returned row references customer A only.
- Status 200 (not 403) on the list endpoints.
- Status 403 on a direct GET of any B-owned resource.
Adding a new scoped list endpoint without registering it in the test table is the failure mode this guards against. The test fails CI when an enumerated endpoint leaks; adding a new list endpoint is on the developer to add to the table.
6. Route & sidebar gating
The mental model: AM = platform_admin scoped to assigned customers. Default allow on customer-side surfaces; deny on internal-infra and the three platform-only floors. Two route lists capture the full denial surface; the existing :id membership check (Section 4) does the customer-level filtering.
Internal-infra routes (denied to scoped roles)
requireUnscoped() middleware — 403s if rc.Scope().Bounded(). AM (and any future scoped internal role like QA in PR3) gets denied; platform_admin, infra_ops, finance_admin, compliance_admin, reader pass through.
| Route | Why hidden |
|---|---|
/api/v1/instances/:id/connection/db-uri | Vault secret — never expose to scope |
/api/v1/instances/:id/connection/redirect-uris | Identity infra |
/api/v1/instances (POST), /api/v1/instances/:id (PUT/PATCH/DELETE) | Instance lifecycle is infra-side |
/api/v1/internal-users/* | AM cannot manage other internal users |
/api/v1/system-workers/* | Internal ops |
/api/v1/plans/*, /api/v1/meters/* | Finance catalog (system-level) |
/api/v1/releases/*, /api/v1/environment/* | Internal ops |
/api/v1/discrepancies/* (system view) | Internal ops |
/api/v1/user-attributes/* (definitions) | Internal ops |
Platform-only routes (above the scope axis)
requirePlatformAdmin() middleware — 403s for everyone except platform_admin. Even AM is denied, by design.
| Route | Why platform-only |
|---|---|
POST /api/v1/customers | Customer creation = commercial gate |
POST /api/v1/tenants/:id/transfer | Cross-customer move; touches billing + ownership |
POST /api/v1/users/:id/transfer-home-org | Cross-customer move; touches identity + audit |
These three are listed across both the doc and docs/0 §3.6 capability matrix as a reminder that they sit above the scope axis.
Customer-internal political acts (customer-side only)
POST /api/v1/customers/:id/transfer-ownership keeps requirePortalRole(owner). No AM bypass. Owner-transfer is a customer-internal political decision (who from the customer's own org chart holds the owner seat). AM observes via the audit log; does not initiate.
Routes AM reaches (everything else)
Default-allow. AM passes:
- All
/api/v1/customers/:id/...for assigned customer IDs (members, tenants, billing, invoices, payments, credits, SSO/IdP, webhooks, service accounts, settings, auth config) /api/v1/tenants/:id/...for tenants whose customer is assigned (manage, delete, members, runtime config)POST /api/v1/customers/:id/tenants— tenant create (per docs/0 §2.1, tenant lifecycle is platform-side; AM has it because they hold platform_admin's action set on their scope)/api/v1/migrations/*for jobs scoped to the AM's customers/api/v1/audit-log,/api/v1/email-logs,/api/v1/changelog,/api/v1/me/*
CustomerScopedMiddleware enforces the :id membership check; list endpoints read scope from request context and AND it into the query via the repository helper (Section 5).
Internal-side permission-map update — internal/app/permissions.go
The legacy RequirePortalRole middleware was designed around portal roles. Internal roles bypass it. AM follows that pattern: bypasses RequirePortalRole for all customer-side actions (view_dashboard, manage_users, manage_tenant_users, view_billing, manage_billing, manage_settings, manage_auth_config, manage_service_accounts, manage_webhooks, etc.). The customer-level filtering happens in CustomerScopedMiddleware, not in RequirePortalRole.
Concretely: do not enumerate AM in portalPermissions[action] lists. The existing internal-bypass branch in RequirePortalRole already covers AM. The denial happens upstream via requireUnscoped() / requirePlatformAdmin() on the routes above.
7. Frontend
Types & permission map
- Add
'account_manager'tointernalRoles(line 36) and the role priority list (line 54). - New helper:ts
export function isScopedInternal(roles?: PortalRole[]) { return roles?.includes('account_manager') && !roles.some(r => unscopedInternalRoles.includes(r)) } canPerform: AM bypasses portal action checks (same as ops_engineer) since scope is enforced at the network layer, not the action layer.
Auth store
[web/src/auth/store.ts] add assignedCustomerIDs?: string[] to the user shape, populated from /me.
/me payload
Backend [internal/handler/users/handler.go] GetMe: when the caller is an AM, include the assigned customer IDs in the response. Used by the frontend to prefilter pickers and to gate cross-customer aggregates.
Customer picker
web/src/components/CommandPalette.tsx and any /customers index pages: when isScopedInternal, call GET /customers?scope=mine (server uses the request scope automatically — no client-side filtering needed for security; this is just a UX hint).
Sidebar — web/src/layout/ConsoleLayout.tsx
Add an account_manager row to the per-role sidebar map:
| Role | Visible Nav Items |
|---|---|
account_manager | Dashboard, Customers (scoped), Tenants (scoped), Instances (scoped, read-only — no create/update), Users (scoped), Billing + Invoices + Payments + Credits (scoped), SSO/IdP, Webhooks, Service Accounts, Customer Settings (per assigned customer), Migrations (scoped), Audit Log (scoped), Email Logs (scoped), Changelog, Account |
Internal-only items hidden: Internal Users, System Workers, Plans, Meters, Releases, Environment, User Attributes, Discrepancies (system), Migrations (system).
Note on aggregates: the same dashboard component is used; for AM the backend returns aggregates scoped to assigned customers. No frontend hide. This same component is reused in PR 2 with a different policy (hide totals).
Assignment management UI
New page: /internal-users/:id/customers — only platform_admin can reach it.
- Multi-select customer picker.
- Lists current assignments with
granted_by+granted_at. - POST/DELETE
/api/v1/internal-users/:id/customer-scopes. - Audit logged.
8. Audit log
Every assignment grant/revoke writes a row:
| Action | Subject | Target |
|---|---|---|
internal.scope.granted | granting platform_admin | (account_manager_user_id, customer_id) |
internal.scope.revoked | revoking platform_admin | (account_manager_user_id, customer_id) |
403s caused by out-of-scope access are not audit-logged here — too noisy. Logged at HTTP access-log level instead.
9. Tests
| Layer | Test |
|---|---|
| DB | Migration up/down idempotency |
| Middleware | internal/auth — AM with scope blocks foreign :id, allows assigned :id |
| Repository | Each list repo with scope=A returns only A rows |
| Integration | scope_drift_test.go (Section 5) — enumerated endpoints |
| Frontend | permissions.test.ts — isScopedInternal, sidebar selection |
| E2E (manual) | AM logs in, sees 2 assigned customers; cannot reach a 3rd via URL bar |
10. Implementation order
| Step | Layer | Description |
|---|---|---|
| 1 | DB | Migration 00X_customer_grants.sql (next unused number) |
| 2 | Backend | internal/model/user.go — RoleAccountManager constant |
| 3 | Backend | internal/app/request.go — CustomerScope type + Scope on claims |
| 4 | Backend | internal/auth/middleware.go — populate Scope from DB |
| 5 | Backend | internal/auth/customer_scope.go — enforce :id membership |
| 6 | Backend | internal/db/scope.go — ApplyCustomerScope helper |
| 7 | Backend | Wire helper into all list repos (Section 5 table) |
| 8 | Backend | requireUnscoped() middleware on internal-infra routes + requirePlatformAdmin() on the three platform-only routes (Section 6) |
| 9 | Backend | /me payload returns assignedCustomerIDs |
| 10 | Backend | Assignment endpoints /api/v1/internal-users/:id/customer-scopes + audit |
| 11 | Backend | scope_drift_test.go integration test |
| 12 | Frontend | permissions.ts — add role + helpers |
| 13 | Frontend | Sidebar gating in ConsoleLayout.tsx |
| 14 | Frontend | Assignment management page |
| 15 | Docs | Update docs/roles-and-permissions.md with new role + scope row |
Steps 1–11 are backend-only and can land first behind a role gate (no AM users yet exist). Steps 12–14 light the feature up.
11. Risk register
| Risk | Mitigation |
|---|---|
| New list endpoint added without scope filter | Drift test (Section 5) — must register endpoint |
| Tenant moved to a customer the AM doesn't manage | Move endpoint requires unscoped role |
| AM assigns themselves to more customers via scope endpoints | Endpoint is requirePlatformAdmin only |
| Aggregate query (e.g. dashboard total) skips scope | All SELECT COUNT(*) FROM customers go through helper; covered by drift test |
| AM also holds another internal role → unintended global access | Assignment UI rejects AM grant if target user has any unscoped role; documented |
| AM treated as less-than-customer-admin in route gating (default-deny on customer-side surfaces) | Default-allow on customer-side routes per Section 6; denials are explicit allowlist of internal-infra + platform-only routes. Drift test (Section 5) catches the inverse failure (AM gaining cross-customer access). |
AM granted access to POST /customers or transfer-tenant because "AM = scoped platform_admin" misread as "AM = full platform_admin" | These routes use requirePlatformAdmin(), not requireUnscoped(). Stricter middleware. |

