Trinity Authorization Pattern¶
hospital-api (Codevertex Afya) implements all three Trinity layers: Layer 1 (JWKS auth), Layer 2 (subscription enforcement, tenant/outlet sync including the self-healing UUID-drift repoint), and Layer 3 (local RBAC + JIT identity + GET /api/v1/{tenant}/hospital/auth/me). Clinical domain schemas and permission-gated clinical routes are not yet built.
Subscription enforcement is mutations-only fleet-wide: reads always pass through; only mutating requests require an active subscription, and a subscription 403 shows an upgrade banner in the frontend rather than redirecting to login.
Overview¶
The Trinity Authorization Pattern combines three layers of access control — identity/RBAC, subscription licensing, and service-level permissions — into a single authorization model used across the Codevertex ecosystem.
Authorization = RBAC (Auth-Service) + Licensing (Subscription-Service) + Resources (Domain Services)
The Three Layers¶
Layer 1: RBAC (Role-Based Access Control) - Auth-Service¶
Purpose: User identity, authentication, and basic role assignments
Owned By: Auth-Service
Components:
- User identity (email, phone, password)
- Global roles (superuser, admin, user)
- Service-specific roles (can be defined by services)
- JWT token issuance with roles and permissions (canonical codes) from a single role–permission table
- Session management
Auth-service permissions scope (Layer 1): Auth-api issues only auth-service permissions in the JWT (e.g. auth.users.view, auth.profile.change). It does NOT issue service-specific permissions such as pos.*.*, ordering.*.*, inventory.*.*, etc. Service-level permissions are managed locally by each service (Layer 3) and are resolved via the service's own /auth/me endpoint after SSO login.
Global roles in JWT: Auth-api issues global canonical roles (superuser, admin, manager, cashier, waiter, kitchen, bar, receptionist, staff, member, rider, viewer). Each service maps these global roles to its own service-level roles via JIT provisioning and the service /auth/me endpoint.
Example Roles:
- superuser - Full access across all services
- admin - Administrative access in specific service
- user - Standard user access
- customer - Ordering service customer
- rider - Logistics service rider
- staff - POS service staff member
Layer 2: Licensing (Feature Entitlements) - Subscription-Service¶
Purpose: Feature availability and usage limits based on subscription plan
Owned By: Subscription-Service
Components: - Subscription plans (Starter, Growth, Professional) - Feature gates (which features are enabled) - Usage limits (max riders, max orders per day, etc.) - Usage tracking (current usage vs limits) - Overage detection and billing
Example Features:
- customer_portal - Basic ordering
- loyalty_program - Loyalty points feature
- multi_outlet - Multiple outlet support
- api_webhooks - Webhook API access
- route_optimization - Advanced routing algorithms
Example Limits:
- max_riders: 15 - Maximum active riders
- max_orders_per_day: 1000 - Maximum orders per day
- max_admins: 3 - Maximum admin users
Layer 3: Resources (Domain-Specific Permissions) - Domain Services¶
Purpose: Fine-grained permissions and resource-level access control
Owned By: Individual Domain Services (Ordering, Logistics, POS, etc.)
Components: - Service-specific permissions - Resource-level access control (e.g., can only edit own orders) - Business rule enforcement - Data ownership and isolation
Service-Level Permission System:
Each domain service implements its own fine-grained permission system stored in its database. Permission codes follow the format {service}.{module}.{action}, with a fixed action vocabulary: add, view, view_own, change, change_own, delete, delete_own, manage, manage_own.
Ent schemas per service (following the treasury-api reference pattern):
- {Service}Permission — permission_code (unique), module, action, resource, description
- {Service}Role — tenant-scoped roles with role_code, is_system_role flag
- RolePermission — many-to-many junction table (role_id + permission_id)
- UserRoleAssignment — tenant_id, user_id, role_id, assigned_by, expires_at
- {Service}User — JIT-provisioned local user ref with auth_service_user_id, sync_status
- RateLimitConfig — DB-loaded rate limit settings per service
- ServiceConfig — key-value config with platform defaults and per-tenant overrides
RBAC module per service (internal/modules/rbac/): service.go, repository.go, repository_ent.go, models.go — provides EnsureUserFromToken (JIT), HasPermission, HasRole, AssignRole, RevokeRole.
Middleware chain (in order): Global rate limit → Auth (JWT/API key via shared-auth-client) → Subscription enforcement (RequireActiveSubscriptionForMutations — mutations only) → JIT user provisioning (with role assignment from JWT) → Outlet context extraction (OutletContext middleware: reads X-Outlet-ID header → stores in context via httpware.WithOutletID(); no-op if header absent) → Route-level RequirePermission/RequireAnyPermission.
Outlet context middleware (all services):
// Applied to /{tenant} route group after TenantV2 and before route handlers
func OutletContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if outletID := httpware.OutletHeader(r); outletID != "" {
r = r.WithContext(httpware.WithOutletID(r.Context(), outletID))
}
next.ServeHTTP(w, r) // always continues — outlet is optional
})
}
Optional outlet filtering in handlers (all services):
outletID := httpware.GetOutletID(ctx)
q := client.Entity.Query().Where(entity.TenantID(tenantUUID))
if outletID != "" {
if uid, err := uuid.Parse(outletID); err == nil {
q = q.Where(entity.OutletIDEQ(uid))
}
}
// Outlet absence is NOT an error — platform admin calls omit it
Subscription enforcement by service (March 2026, updated March 29):
- Enforced (mutations only): treasury-api, inventory-api, pos-api, ordering-backend, logistics-api, projects-api, marketflow-api — all use mutations-only enforcement: GET/HEAD/OPTIONS pass through, POST/PUT/PATCH/DELETE require active subscription.
- NOT enforced (core services, free in all plans): auth-api (token authority), subscriptions-api (subscription authority), notifications-api (core messaging). Notifications uses plan-based email rate limiting (max_emails_per_day from JWT SubscriptionLimits) instead of subscription gating.
- Superuser and platform owner always bypass subscription enforcement.
Subscription enforcement rules:
- Subscription NEVER blocks authentication or login. Users must always be able to log in regardless of subscription status.
- Read operations (GET) are always allowed so users can view their data even with an expired subscription.
- Mutation operations (POST/PUT/PATCH/DELETE) are blocked with HTTP 403 and response body {"error":"...","code":"subscription_inactive","upgrade":true}.
- The upgrade: true field in the JSON response distinguishes subscription 403s from auth/permission 403s.
- Frontends must discriminate subscription 403s from auth 403s: subscription 403 → show upgrade banner/toast, NOT redirect to login. Auth 403 → redirect to unauthorized page.
Frontend subscription gating pattern:
Each frontend implements lazy subscription loading via useSubscription() hook + SubscriptionBanner (persistent top banner) + SubscriptionGate (wraps gated features). Subscription info is fetched AFTER login from subscriptions-api; loading never blocks UI. Platform owners get automatic active/enterprise status. See subscription-gating-guide.md for the full implementation guide (backend middleware code, frontend error-handler wiring, the mutations-only services table, and the step-by-step checklist for adding gating to a new service) — this file states the policy, that one is the how-to.
Example service-level permissions:
- treasury.payments.add, treasury.payments.view, treasury.payments.manage
- ordering.orders.add, ordering.catalog.change, ordering.config.manage
- logistics.tasks.add, logistics.fleet.manage, logistics.zones.view
- notifications.templates.change, notifications.providers.manage
- marketflow.leads.add, marketflow.campaigns.manage, marketflow.chat.view
Relationship to Layer 1 (auth-service) permissions:
Layer 1 canonical codes (e.g. catalog:view) are global cross-cutting codes issued in JWT by auth-service. Layer 3 service-level codes (e.g. ordering.catalog.view) are fine-grained codes managed locally by each service. Both can coexist: the shared-auth-client RequirePermission middleware checks claims.Permissions (from JWT), while the service RBAC module checks local DB permissions via rbacService.HasPermission().
Superuser bypass: All permission checks (both JWT-level and service-level) are bypassed for users with the superuser role. Platform owner (is_platform_owner) bypasses tenant isolation and platform route restrictions.
Just-in-Time (JIT) provisioning: When a microservice receives a valid JWT but has no local user record for sub, it should create a minimal user from token claims and then proceed (not return 401). This avoids "user not found" 401s when NATS sync is delayed. Resource-level (Layer 3) checks still apply after the user exists. JIT must also assign a default service-level role based on global JWT roles (e.g. superuser/admin → service admin, staff → manager/operator, others → viewer). This ensures local RBAC queries return correct role data for role-based UI gating and RBAC management endpoints. All services now implement this: treasury-api (finance_admin), inventory-api (inventory_admin), pos-api (admin), logistics-api (admin), notifications-api (super_admin), marketflow-api (marketflow_admin).
Service auth/me endpoint (Layer 3 enrichment): Every domain service must expose GET /{tenant}/auth/me (or equivalent) that returns the user's service-level role and fine-grained service permissions merged from the local RBAC tables. Frontends call this endpoint immediately after SSO login (after getting SSO identity from auth-api /api/v1/auth/me) to obtain service-specific RBAC data:
1. SSO callback → exchange code → get access_token
2. Call SSO GET /api/v1/auth/me → get user identity + global roles + auth.*.* perms
3. Call service GET /{tenant}/{service}/auth/me → get service role + service.*.* perms
- Service maps global JWT roles to local service role via JIT
- Returns: { user_id, email, name, global_roles, service_role, permissions: [...] }
4. Frontend stores merged profile: identity from SSO + permissions from service /auth/me
5. Sidebar, page guards, action buttons check service permissions (pos.orders.add, etc.)
Services implementing this pattern:
- pos-api: GET /{tenant}/pos/auth/me → returns pos_role + pos.. permissions
- ordering-backend: GET /{tenant}/auth/me → ordering role + permissions (reference implementation)
- treasury-api: GET /api/v1/auth/me → finance role + permissions
- notifications-api: GET /{tenant}/auth/me → notifications role + permissions
JIT tenant sync: All Go backends must sync the tenant from auth-api when the request carries a tenant slug (e.g. from JWT or path). If the slug is present and the tenant is missing locally, the service should fetch and upsert the tenant from auth-api before processing the request. This avoids "tenant not found" after SSO login when the token was minted for a tenant (via ?tenant= on the authorize URL).
Outlet/branch context (Layer 3 extension): Services that support multi-outlet operations (ordering, inventory, logistics, POS, treasury) accept X-Outlet-ID (outlet UUID) as an optional header. When present, handlers filter their data to that outlet. When absent, all tenant-scoped data is returned. This allows platform owners and HQ admin users to see cross-outlet aggregates while individual staff see only their assigned outlet. CORS AllowedHeaders must include X-Outlet-ID on all services. See sso-integration-guide.md → Outlet/Branch Context section for frontend preselection rules and the select-outlet page pattern.
Tenant ID format: Frontends must send X-Tenant-ID as the tenant UUID from auth-api (e.g. from GET /api/v1/auth/me response tenant_id). Do not send a slug or custom string (e.g. tenant-acme-retail). Auth-api and all SSO-integrated backends must include X-Tenant-ID in CORS Access-Control-Allow-Headers (app and ingress) so browser preflights succeed.
Auth/me caching: Auth-api caches GET /api/v1/auth/me in Redis by user ID with TTL = token expiry. Frontends should use TanStack Query (or similar) with a TTL aligned to token lifetime so the first read is fast and DB load is reduced.
Claims best practices: Keep claims in the token stable (e.g. user ID, tenant, roles, permissions). Avoid putting volatile or rarely used data in the token; services can resolve fine-grained rules from role/claims locally.
Integration Flow¶
1. User Authentication (Auth-Service)¶
// User logs in
POST /api/v1/auth/login
{
"email": "user@example.com",
"password": "password",
"tenant_slug": "urban-cafe"
}
// Auth-service validates credentials and issues JWT
Response:
{
"access_token": "jwt-token",
"refresh_token": "refresh-token",
"user": {
"id": "user-uuid",
"email": "user@example.com",
"tenant_id": "tenant-uuid",
"roles": ["admin", "user"]
}
}
2. JWT Claims Extension (Subscription-Service)¶
Auth-api calls GET /api/v1/tenants/{tenant_id}/subscription on subscriptions-api (with X-API-Key: INTERNAL_SERVICE_KEY) to fetch subscription data at token issuance. The subscription fields are embedded directly in the JWT using short sub_* key names.
Endpoint: GET /api/v1/tenants/{tenant_id}/subscription (S2S, API key auth)
3. Enhanced JWT Token¶
{
"sub": "user-uuid",
"tenant_id": "tenant-uuid",
"email": "user@example.com",
"roles": ["admin", "user"],
"sub_plan": "ORDERING-GROWTH-MONTHLY",
"sub_status": "ACTIVE",
"sub_features": [
"customer_portal",
"loyalty_program",
"multi_outlet"
],
"sub_limits": {
"max_riders": 15,
"max_orders_per_day": 1000,
"max_admins": 3
},
"sub_expires": 1751328000,
"exp": 1234567890,
"iat": 1234567890
}
Key names:
sub_plan,sub_status,sub_features,sub_limits,sub_expires— NOTsubscription_plan,subscription_features, etc. Thesub_*prefix is the canonical JWT form. The long names exist only in internal DB columns and admin API responses.
4. Request Authorization (Domain Service)¶
// Domain service validates authorization (Go — using shared-auth-client)
claims, ok := authclient.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", 401)
return
}
// Step 1: RBAC — check service-specific permissions from local RBAC
if !rbacService.HasPermission(ctx, claims.UserID, "ordering.orders.add") {
http.Error(w, `{"error":"forbidden"}`, 403)
return
}
// Step 2: Licensing — subscription gate (already applied by mutations-only middleware)
// claims.IsSubscriptionActive() || claims.IsSuperuser() || claims.IsPlatformOwner
// Step 3: Feature check from JWT claims (no HTTP call needed)
hasFeature := false
for _, f := range claims.SubscriptionFeatures {
if f == "customer_portal" { hasFeature = true; break }
}
if !hasFeature {
http.Error(w, `{"error":"feature not available on current plan"}`, 403)
return
}
// Step 4: Limit check from JWT claims
maxOrders := claims.SubscriptionLimits["max_orders_per_day"]
if maxOrders > 0 && todayOrders >= maxOrders {
http.Error(w, `{"error":"daily order limit exceeded"}`, 403)
return
}
// Step 5: Create order
Implementation Patterns¶
Pattern 1: Feature Gate Check¶
// Before allowing feature access
const checkFeature = async (tenantId: string, featureCode: string): Promise<boolean> => {
// Check cache first (Redis)
const cacheKey = `subscription:feature:${tenantId}:${featureCode}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
return cached === "true";
}
// Check subscription service
const hasFeature = await subscriptionService.HasFeature(tenantId, featureCode);
// Cache result (60s TTL)
await redis.set(cacheKey, hasFeature ? "true" : "false", "EX", 60);
return hasFeature;
};
// Usage
if (!await checkFeature(tenantId, "loyalty_program")) {
return ErrFeatureNotAvailable;
}
Pattern 2: Limit Enforcement¶
// Before creating resource
const checkLimit = async (
tenantId: string,
metricType: string,
limitName: string
): Promise<{ allowed: boolean; current: number; limit: number }> => {
// Get limits from JWT claims or subscription service
const limits = await subscriptionService.GetLimits(tenantId);
const currentUsage = await subscriptionService.GetUsage(tenantId, metricType, "current");
const limit = limits[limitName];
return {
allowed: currentUsage < limit,
current: currentUsage,
limit: limit,
};
};
// Usage
const limitCheck = await checkLimit(tenantId, "rider_count", "max_riders");
if (!limitCheck.allowed) {
// Option 1: Reject
return ErrLimitExceeded(`Maximum ${limitCheck.limit} riders allowed`);
// Option 2: Allow with overage
await subscriptionService.ReportOverage(tenantId, "rider_count", 1);
}
Pattern 3: Usage Reporting¶
// After creating resource
const reportUsage = async (
tenantId: string,
metricType: string,
value: number,
metadata?: any
) => {
await subscriptionService.ReportUsage(tenantId, metricType, value, metadata);
};
// Usage
await createOrder(orderData);
await reportUsage(tenantId, "order_count", 1, {
order_id: order.id,
date: new Date().toISOString().split('T')[0],
});
Pattern 4: Trinity Authorization Middleware¶
// Express/Next.js middleware
const trinityAuth = (
requiredPermissions: string[],
requiredFeatures: string[] = [],
resourceCheck?: (req: Request) => Promise<boolean>
) => {
return async (req: Request, res: Response, next: NextFunction) => {
// Step 1: Extract and validate JWT
const token = extractToken(req);
const claims = await validateJWT(token);
if (!claims) {
return res.status(401).json({ error: "Unauthorized" });
}
// Step 2: Check RBAC permissions
const hasPermissions = requiredPermissions.every(perm =>
hasPermission(claims.roles, perm)
);
if (!hasPermissions) {
return res.status(403).json({ error: "Insufficient permissions" });
}
// Step 3: Check subscription features
const hasFeatures = requiredFeatures.every(feature =>
claims.subscription_features?.includes(feature)
);
if (!hasFeatures) {
return res.status(403).json({
error: "Feature not available on current plan",
required_features: requiredFeatures,
available_features: claims.subscription_features,
});
}
// Step 4: Check resource-level permissions (if provided)
if (resourceCheck) {
const hasResourceAccess = await resourceCheck(req);
if (!hasResourceAccess) {
return res.status(403).json({ error: "Resource access denied" });
}
}
// Attach claims to request for use in handlers
req.user = claims;
next();
};
};
// Usage
app.post(
'/api/v1/orders',
trinityAuth(
['orders:create'], // Required permissions
['customer_portal'], // Required features
async (req) => {
// Resource-level check: can only create orders for own tenant
return req.user.tenant_id === req.body.tenant_id;
}
),
createOrderHandler
);
Plan Transitions & Grace Periods¶
Upgrade Flow¶
// User upgrades plan
1. Subscription service creates plan transition record
2. Calculates proration
3. Emits billing event to treasury
4. After payment confirmation:
- Updates subscription status
- Activates new features
- Updates JWT claims (on next token refresh)
- Emits subscription.entitlements_changed event
5. All services refresh feature gates
Downgrade Flow¶
// User downgrades plan
1. Subscription service schedules downgrade (period-end or immediate)
2. Before downgrade:
- Checks if current usage exceeds new plan limits
- Shows warning if limits will be exceeded
- Allows user to cancel downgrade
3. On downgrade:
- Deactivates premium features
- Updates limits
- Emits subscription.entitlements_changed event
4. Services gracefully disable premium features
Grace Period¶
// Subscription expires
1. Subscription service marks subscription as expired
2. Grace period starts (e.g., 7 days)
3. During grace period:
- Features remain active
- Usage tracking continues
- Warnings shown to users
4. After grace period:
- Features disabled
- Access restricted (read-only mode)
- Billing retry attempts continue
Best Practices¶
1. Cache Feature Gates¶
Rationale: Feature checks happen frequently; caching reduces latency
Implementation: - Cache in Redis with 60s TTL - Invalidate cache on subscription changes - Use stale-while-revalidate pattern
2. Fail Closed for Feature Checks¶
Rationale: If subscription service is unavailable, deny access rather than allow
Implementation: - If feature check fails → assume feature unavailable - Log error for monitoring - Alert operations team
3. Real-time Usage Reporting¶
Rationale: Accurate usage tracking enables proper limit enforcement
Implementation: - Report usage immediately after action - Batch multiple reports for efficiency - Retry on failure with exponential backoff
4. Soft Limits with Overage¶
Rationale: Better user experience - allow usage but charge for overages
Implementation: - Allow action even if limit exceeded - Track overage quantity - Calculate overage charges daily - Emit billing events for overages
5. JWT Claims Caching¶
Rationale: Reduce calls to subscription service for every request
Implementation: - Include subscription data in JWT claims - Cache claims for token lifetime - Refresh claims on token refresh - Invalidate cache on subscription changes
Product-Level Entitlements (Added February 2026)¶
Layer Between RBAC and Feature Licensing¶
Products represent the bridge between RBAC (who can access) and Feature Licensing (what's available):
Product activation flow:
1. Tenant subscribes to a bundle (e.g., Professional)
2. Bundle activates products: ordering, logistics, treasury, notifications, auth
3. Active product IDs are embedded in JWT claims
4. Frontend checks jwt.products array before rendering service-specific UI
5. Backend middleware validates product access before processing API calls
Product → Service mapping:
| Product | Service | Frontend App |
|---|---|---|
| ordering | ordering-service | ordering-frontend |
| logistics | logistics-service | rider-app, logistics-ui |
| treasury | treasury-service | (embedded in ordering) |
| pos | pos-service | pos-frontend |
| analytics | analytics-service | (embedded dashboard) |
| notifications | notifications-service | (backend only) |
| auth | auth-service | auth-ui |
| inventory | inventory-service | inventory-frontend |
| hospital | hospital-service (Codevertex Afya — all three Trinity layers implemented: JWKS auth, subscription gating, local RBAC/JIT/auth-me; no clinical domain schemas yet) | hospital-ui (live, afya.codevertexafrica.com) |
Bundle-Based Activation¶
| Bundle | Products Included | Price Tier |
|---|---|---|
| Starter | ordering, auth | Entry-level |
| Professional | ordering, logistics, treasury, notifications, auth | Mid-tier |
| Enterprise | All 8 products | Full platform |
Monitoring & Alerts (Design Intent)¶
These are the metrics and alerts this authorization model is designed to be instrumented for, once a metrics pipeline is in place — see Observability for the logging and tracing that exist today. Treat the list below as design intent, not a live dashboard.
- Feature check latency and success/failure rate
- Usage reporting latency and failures
- Limit enforcement and overage detection accuracy
- Plan transition success/failure rate
- Subscription service availability
References¶
Platform Owner Pattern (Codevertex)¶
Codevertex is NOT a business tenant — it is the platform owner. Any user whose primary_tenant = "codevertex" and who has the superuser role has cross-tenant access to all tenants' data.
Subscription and tenant sync¶
- Subscription-service must NOT create tenant subscriptions for the platform owner. Only business (customer) organisations have subscriptions. The subscription-api seed excludes the platform owner slug so no
TenantSubscriptionis created for Codevertex. - All services that sync tenants (in seed or at startup) must sync the codevertex platform org in addition to other default tenants, so the platform tenant row exists in each service DB (e.g. ordering-backend, inventory-api, subscriptions-api app, notifications-api seed).
- Platform org admin user: Each service that maintains local user/role/permission data must sync or JIT-provision the platform admin user (auth-api super admin, e.g.
admin@codevertexafrica.com) and assign all permissions in that service as the global admin user (e.g. superuser + admin roles in ordering-backend, finance_admin in treasury-api, admin role in logistics-api JIT).
Token Claims¶
GET /api/v1/auth/me returns is_platform_owner: true when the user's primary tenant slug is codevertex. Services should grant full read/write when this flag is set:
if claims.roles.includes("superuser") || user.is_platform_owner:
→ bypass tenant isolation checks
→ allow reading/writing any tenant's data
Backend Tenant Override for Platform Owners¶
All tenant-scoped handlers support ?tenantId=<uuid> query parameter for platform owners (March 2026). The standard pattern:
func getTenantID(r *http.Request) (uuid.UUID, error) {
ctx := r.Context()
// Platform owner can target any tenant via query param
if httpware.IsPlatformOwner(ctx) {
if q := r.URL.Query().Get("tenantId"); q != "" {
return uuid.Parse(q)
}
}
// Standard resolution: httpware context → headers → JWT claims
tenantIDStr := httpware.GetTenantID(ctx)
return uuid.Parse(tenantIDStr)
}
Frontend pattern: Platform owner UIs do NOT send X-Tenant-ID/X-Tenant-Slug headers. Instead, a centralized TenantFilter component lets the platform owner select a tenant, and the selected ID is passed as ?tenantId= on API calls. When "All Tenants" is selected, no tenantId param is sent and the backend returns cross-tenant data for list endpoints.
Auth-API Seed Logic¶
The auth-api seed creates the codevertex tenant first, then creates Codevertex-owned users (e.g. admin@codevertex.dev) with superuser membership. All other tenants are then seeded as business clients.
Account Creation & Subscription Enforcement Flow¶
Registration Flow (auth-ui multi-step)¶
Step 1: Account Info (name, email, password)
↓
Step 2: Organisation
├── Join Existing: search by slug → GET /api/v1/tenants/by-slug/{slug} (public)
│ → user joins with `member` role
└── Create New: provide name, slug, org_size, use_case
→ POST /api/v1/auth/register with org_action=create_new
→ auth-api creates tenant, assigns user `admin` role (tenant founder)
→ publishes tenant.created event (downstream services sync)
↓
Step 3: Subscription Recommendation
→ Fetch plans from GET /api/v1/plans (subscription-api)
→ Display plans with features, limits, 14-day free trial
→ User selects preferred plan (stored as profile.selected_plan)
↓
Submit → POST /api/v1/auth/register → redirect to /login
↓ After login ↓
If not platform user (primary_tenant ≠ codevertex):
→ Check subscription-api: GET /api/v1/tenants/{id}/subscription
→ If ACTIVE or TRIAL: allow full access
→ If no subscription / EXPIRED: redirect to /subscribe page
→ Show subscription plans, free trial CTA
→ User selects plan → subscription-api provisions trial
Subscription Member Limits¶
When a tenant's subscription tier has max_admins = 2:
- The 3rd user trying to register as admin for that org is blocked at registration
- Error returned: subscription_limit_exceeded with tier details
- Implementation: Register() checks GET /api/v1/tenants/{id}/subscription before creating membership (planned — not yet enforced in code; tracked for next sprint)
Login Post-Auth Subscription Check¶
After successful login, every frontend (except auth-ui itself) must:
1. Receive access_token from auth-api
2. Check user.is_platform_owner
→ true: skip subscription check, grant full access
3. Call GET /api/v1/tenants/{tenant_id}/subscription (subscription-api)
→ status=ACTIVE or TRIAL: allow access
→ status=EXPIRED or no record: redirect to subscription page
Subscription Seed UUID Resolution¶
Critical: The subscription-api seed must NEVER hardcode tenant UUIDs. Auth-api generates UUIDs at runtime (DB-generated). Hardcoded UUIDs will never match.
Resolution Order¶
- Env var override:
TENANT_ID_{SLUG_UPPER}(e.g.TENANT_ID_ACME_RETAIL=<uuid>) - Auth-api public endpoint:
GET /api/v1/tenants/by-slug/{slug}(no auth required) - Skip with warning: if auth-api unreachable and no env var, log and continue
Env Var Override (for CI/offline seeding)¶
TENANT_ID_ACME_RETAIL=<uuid-from-auth-api-db>
TENANT_ID_CODEVERTEX=<uuid-from-auth-api-db>
AUTH_API_URL=https://sso.codevertexafrica.com # default
Run Order¶
Auth-api seed must run before subscription-api seed so tenants exist in auth-api DB.