Appearance
Role Enforcement — Portal & Internal
This document describes the permission model and enforcement strategy for Console's three role systems: Internal Users, Customer Portal Users, and Tenant-Level Roles.
1. Role Definitions
1.1 Internal Users (internal_users collection)
| Role | Description |
|---|---|
platform_admin | Full access to all Console admin features |
ops_engineer | Operations — instances, migrations, etc. |
finance_admin | Finance-only — billing, plans, meters |
compliance_admin | Compliance/audit — read access to System Workers, Discrepancies, Audit Log, Email Logs. Receives discrepancy email alerts. No finance. |
reader | View-only across the portal (instances, customers, tenants, users, releases, migrations, system pages). Excludes finance. |
The roles column on internal_users is TEXT[]; a user may hold multiple roles. Permission checks union all roles. The "primary" role for nav/label purposes follows a fixed priority: platform_admin > ops_engineer > finance_admin > compliance_admin > reader.
1.2 Customer Portal Users (customer_users collection — portalRole field)
| Role | Description |
|---|---|
owner | Full control. One per customer. Can manage admins, transfer ownership, delete account. |
admin | Manage users, tenants, settings, auth config, webhooks, service accounts. Cannot promote to admin/owner. |
billing | View dashboard/tenants/instances + manage billing & invoices. |
viewer | Read-only access to dashboard, tenants, instances. |
member | No portal access. Can only access /me/* routes (profile, MFA). Auto-assigned when a user is added to a tenant without an existing portal role. |
1.3 Tenant-Level Roles (tenant_user_assignments collection — role field)
| Role | Description |
|---|---|
tenant_admin | Admin within the tenant (enforced by exto-go instance) |
tenant_user | Regular user within the tenant |
These roles are passed to the exto-go instance during user provisioning. The instance enforces what each role can do within the tenant.
2. Permission Matrix — Internal Users
R = read-only access. Y = read + write. - = no access.
| Capability | platform_admin | ops_engineer | finance_admin | compliance_admin | reader |
|---|---|---|---|---|---|
| Dashboard, Tenant Insights | Y | Y | Y | R | R |
| Instances, Customers, Tenants | Y | Y | - | - | R |
| Releases, Changelog, Environment | Y | Y | - | - | R |
| Internal Users (list) | Y | R | R | R | R |
| Internal User Invite/Edit | Y | - | - | - | - |
| Migrations (list) | Y | Y | - | - | R |
| Migrations (start/rerun/rollback) | Y | Y | - | - | - |
| System Workers | Y | Y | - | R | R |
| Discrepancies (inbox) | Y | Y | - | R | R |
| Discrepancies (purge/discard) | Y | Y | - | - | - |
| Trigger Discrepancy Audit | Y | Y | - | - | - |
| Audit Log, Email Logs | Y | Y | Y | R | R |
| User Attributes (definitions) | Y | - | - | - | - |
| Resolve DB URI (vault secrets) | Y | - | - | - | - |
| Billing Settings, Invoices, Payments | Y | - | Y | - | - |
| Plans, Meters, Usage Aggregates | Y | - | Y | - | - |
| Tenant Billing Config, Cascade Preview | Y | - | Y | - | - |
| Custom Plans, Credit Management | Y | - | Y | - | - |
UI note: Each role gets a tailored sidebar:
- platform_admin / ops_engineer: full internal navigation (Finance group shown for platform_admin / users that also hold
finance_admin). - finance_admin: finance-only sidebar.
- compliance_admin: audit-focused sidebar (Dashboard, Tenant Insights, System Workers, Discrepancies, Audit Log, Email Logs, Changelog, Account).
- reader: full internal navigation minus the Finance group and minus admin-only items (User Attributes).
Backend enforcement:
- Operational internal roles (
platform_admin,ops_engineer,finance_admin) bypassRequirePortalRolechecks entirely. - Read-only internal roles (
compliance_admin,reader) bypass non-financialRequirePortalRoleactions but are explicitly denied onview_billingandmanage_billing. - Per-handler guards:
requireOpsOrAdmin(writes on system / discrepancies / migrations),requireFinanceAdmin(all billing writes + many finance reads),requirePlatformAdmin(user attributes, internal-user invite/edit, move customer user, claim ownership). - Per-handler read guard:
requireInternalRead(system workers list, tenant discrepancy list) admits ops/admin + compliance_admin + reader.
3. Permission Matrix — Customer Portal Users
3.1 Portal Actions
| Action | owner | admin | billing | viewer | member |
|---|---|---|---|---|---|
| Portal Dashboard | Y | Y | Y | Y | - |
| View Tenants & Instances | Y | Y | Y | Y | - |
| View Users | Y | Y | - | - | - |
| Manage Users (invite, suspend, reactivate) | Y | Y | - | - | - |
| Manage Tenant Users (assign/remove) | Y | Y | - | - | - |
| View Billing & Invoices | Y | Y | Y | - | - |
| Manage Billing | Y | Y | Y | - | - |
| Customer Settings | Y | Y | - | - | - |
| Auth Config (SSO, IdPs) | Y | Y | - | - | - |
| Service Accounts | Y | Y | - | - | - |
| Webhooks | Y | Y | - | - | - |
| Account Settings (own profile) | Y | Y | Y | Y | Y |
| Changelog | Y | Y | Y | Y | - |
3.2 Role Management (who can assign which portalRole)
| Actor | Can assign | Cannot assign |
|---|---|---|
owner | admin, billing, viewer, member | owner (use transfer) |
admin | billing, viewer, member | owner, admin |
- Only
ownercan promote/demote admins. - Admins cannot modify other admins or the owner.
- Owner transfer is a dedicated action (see section 5).
3.3 Member Role Behaviour
- Blocked from all portal routes by
CustomerScopedMiddleware(HTTP 403). - Can only access
/me/*routes (profile, MFA). - Gets a restricted top-bar layout with no sidebar.
- Redirected to
/customers/:id/accountafter login. - Auto-assigned when a user is added to a tenant without an existing portal role (handled in
AssignUserToTenantandInviteTenantUser).
4. Owner-Only Capabilities
These actions are reserved exclusively for the owner role:
| Capability | Detail |
|---|---|
| Transfer ownership | Designate another portal user as the new owner. The current owner is demoted to admin. Atomic operation. |
| Promote to admin | Only the owner can set portalRole = "admin" on another user. |
| Demote admin | Only the owner can change an admin's role to billing/viewer/member. |
| Delete customer account | Soft-delete the customer organisation (future — requires confirmation flow). |
Constraint: There must always be exactly one owner per customer. The system must prevent:
- Removing the owner without transferring ownership first.
- Self-demotion if you are the only owner.
5. Enforcement Architecture
5.1 Backend Layers
Request
│
▼
JWKSMiddleware → JWT signature validation
│
▼
RequestContextMiddleware → Resolve user type + role from DB
│ (internal_users or customer_users)
│
▼
CustomerScopedMiddleware → Scope check (customer can only access own data)
│ Block member role from portal routes
│ Internal users bypass
│
▼
requirePortalRole(action) → Per-route permission check
│ Uses central permission map
│ Internal users bypass
│
▼
Service Layer → Role-change validation
(who can assign which portalRole)
Owner transfer logic5.1.1 Permission Map (internal/app/permissions.go)
Central map[action][]allowedRoles defining all portal permissions. Single source of truth for both requirePortalRole middleware and service-layer validation.
Helper functions:
CanPerform(role string, action string) boolAllowedRoleChanges(actorRole string) []string
5.1.2 Route Middleware (internal/auth/customer_scope.go)
requirePortalRole(actions ...string) — Gin middleware factory:
- Extract
RequestClaimsfrom context. - Internal user → allow (bypass).
- Check
CanPerform(claims.Role, action)for each action. - Deny → HTTP 403
{"error": "insufficient permissions"}.
Applied per-route in cmd/api/main.go:
| Route Pattern | Required Action |
|---|---|
GET /customers/:id/users | view_users |
POST /customers/:id/users/invite | manage_users |
POST /customers/:id/users/invite-bulk | manage_users |
PUT /customers/:id/users/:uid | manage_users + service-layer validation |
POST /customers/:id/users/:uid/suspend | manage_users |
POST /customers/:id/users/:uid/reactivate | manage_users |
GET /customers/:id/billing/* | view_billing |
PUT /customers/:id/settings | manage_settings |
* /customers/:id/auth-config/* | manage_auth_config |
* /customers/:id/service-accounts/* | manage_service_accounts |
* /customers/:id/webhooks/* | manage_webhooks |
5.1.3 Service-Layer Validation (internal/handler/users/service.go)
UpdateCustomerUser — before changing portalRole:
- Resolve actor role and target user's current role.
- Check
AllowedRoleChanges(actorRole)includes the new role. - Prevent self-demotion for the only owner.
- For owner transfer: atomically set new owner + demote old owner to admin.
5.2 Frontend Layers
useConsoleAuth (Zustand store)
│
▼
permissions.ts → canPerform(role, action) mirror of backend map
│ useCanPerform(action) hook
│
▼
<RequirePortalRole> → Route guard component
│ Renders "access denied" or redirects if denied
│
▼
CustomerPortalLayout → Sidebar filtering based on canPerform
│
▼
Page Components → Show/hide action buttons (invite, suspend, role
dropdowns) based on canPerform5.2.1 Permission Module (web/src/auth/permissions.ts)
- Mirrors backend permission map.
canPerform(role, action): booleanuseCanPerform(action): boolean— hook usinguseConsoleAuth.allowedRoleChanges(actorRole): string[]— for role dropdown options.
5.2.2 Route Guard (web/src/auth/RequirePortalRole.tsx)
Wrapper component for portal routes:
tsx
<RequirePortalRole actions={["view_users"]}>
<CustomerUsersPage />
</RequirePortalRole>Internal users always pass. Denied users see an "insufficient permissions" page.
Applied in web/src/routes/index.tsx:
| Route | Required Action |
|---|---|
/dashboard | view_dashboard |
/tenants | view_tenants |
/users | view_users |
/billing | view_billing |
/settings | manage_settings |
/account | (no guard) |
5.2.3 Sidebar Filtering (web/src/layout/CustomerPortalLayout.tsx)
Filter navigation items by canPerform:
| Role | Visible Nav Items |
|---|---|
owner | All |
admin | All |
billing | Dashboard, Tenants, Instances, Billing, Changelog, Account |
viewer | Dashboard, Tenants, Instances, Changelog, Account |
member | Account only (restricted layout, no sidebar) |
5.2.4 Per-Page Action Visibility
- Users page: Hide "Invite" button unless
manage_users. Role dropdown only shows options fromallowedRoleChanges(actorRole). - Tenant detail: Hide "Assign Users" / "Invite" unless
manage_tenant_users. - Settings page: Disable form fields unless
manage_settings. - Owner transfer: Visible only to current owner, with confirmation dialog.
6. Key Enforcement Points (Summary)
| Layer | File | What it does |
|---|---|---|
| JWT validation | internal/auth/middleware.go | Verify token, resolve user type/role |
| Customer scoping | internal/auth/customer_scope.go | Scope isolation + block member role |
| Route permissions | internal/auth/customer_scope.go | requirePortalRole per-route checks |
| Role-change logic | internal/handler/users/service.go | Who can assign which portalRole |
| Internal admin | internal/handler/billing/handler.go | requireFinanceAdmin() |
| Internal admin | internal/handler/migrations/handler.go | requireOpsOrAdmin() |
| Internal admin | internal/app/request.go | IsPlatformAdmin() |
| Frontend perms | web/src/auth/permissions.ts | Client-side permission map + hooks |
| Frontend guards | web/src/auth/RequirePortalRole.tsx | Route-level access control |
| Frontend nav | web/src/layout/CustomerPortalLayout.tsx | Sidebar filtering by role |
The backend is the source of truth. Frontend checks are for UX only — the server returns 403 for any unauthorized request regardless of what the UI shows.
7. Implementation Order
| Step | Scope | Description |
|---|---|---|
| 1 | Backend | internal/app/permissions.go — permission map + helpers |
| 2 | Backend | internal/auth/customer_scope.go — requirePortalRole middleware |
| 3 | Backend | cmd/api/main.go — apply middleware to portal routes |
| 4 | Backend | internal/handler/users/service.go — role-change validation + owner transfer |
| 5 | Frontend | web/src/auth/permissions.ts — permission map + hooks |
| 6 | Frontend | web/src/auth/RequirePortalRole.tsx — route guard component |
| 7 | Frontend | web/src/routes/index.tsx — wrap portal routes with guards |
| 8 | Frontend | web/src/layout/CustomerPortalLayout.tsx — sidebar filtering |
| 9 | Frontend | Portal pages — show/hide action buttons based on permissions |
| 10 | Both | Ownership transfer endpoint + UI |
Steps 1-4 and 5-6 can be done in parallel. Steps 7-9 depend on 5-6.

