# Platform Service Architecture

## Purpose & Audience

This document describes the architecture of the platform service (`apps/platform`). It is intended for engineers who need to understand, debug, extend, or operate the service. For operational details, see the [platform README](../../apps/platform/README.md). For security specifics, see [security.md](../compliance/security.md).

---

## System Overview

The platform service is a ConnectRPC microservice that owns all authorization, multi-tenancy, compliance, and entitlement management for the Coda platform. It replaces the previous stateless JWT + email-allowlist auth model with a full enterprise RBAC+ABAC permission system. Every API request in the system calls the platform service to resolve permissions before proceeding.

```
                    ConnectRPC (internal VPC, port 8082)

 ows-coda server ───────────────┐
 (Express)                      |
                                v
 ows-coda search ──────────┐   [Platform Service (Fargate)]
 (ConnectRPC)              |    |                          |
                           v    |  ┌────────┐ ┌─────────┐  |
 future services ─────────────> |  │ access │ │platform │  |
                                |  │ domain │ │ domain  │  |
                                |  └────────┘ └─────────┘  |
                                |  ┌──────────┐ ┌───────┐  |
                                |  │compliance│ │growth │  |
                                |  │  domain  │ │domain │  |
                                |  └──────────┘ └───────┘  |
                                |                          |
                                |  In-process event bus     |
                                └──────────────────────────┘
                                           |
                              ┌────────────┼──────────┐
                              v            v          v
                     ┌──────────────┐ ┌──────┐ ┌──────┐
                     │ Aurora MySQL  │ │Redis │ │  S3  │
                     │ (shared DB)   │ │      │ │(arch)│
                     └──────────────┘ └──────┘ └──────┘
```

---

## Domain Decomposition

The service is decomposed into four domains, each owning a distinct concern. They share Aurora MySQL but have separate proto packages, handlers, and (eventually) separate deployments. The proto package split ensures decomposition later is a routing change, not a rewrite.

| Domain         | Responsibility                                                                  | Hot/Cold Path                    | Package                                                 |
| -------------- | ------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------- |
| **Access**     | Authorization, entitlements, plans, credits, quotas, rate limits, sessions      | Hot (every request)              | `@coda/admin-api`                                       |
| **Platform**   | Tenants, identity, users, groups, departments, branding, invitations, SCIM      | Warm (cached reads, cold writes) | `@coda/admin-api`                                       |
| **Compliance** | Audit logs, consent, privacy requests, external references, data classification | Async writes, cold reads         | `@coda/admin-api` (co-located; future separate package) |
| **Growth**     | Promotions, codes, redemptions, referrals                                       | Cold (low frequency)             | `@coda/admin-api` (co-located; future separate package) |

### Package layout

```
packages/admin-api/          @coda/admin-api
  proto/coda/admin/v1/
    access.proto              AccessService (hot path — Check, CheckBatch, GetEffective, ResolveTenant)
    access_types.proto        Shared access types
    role.proto                RoleService, PolicyService
    policy.proto              PolicyCondition types
    session.proto             SessionService
    cache.proto               PermissionCache types
    tenant.proto              TenantService
    identity.proto            IdentityService
    types.proto               Shared message types
  gen/ows/access/v1/          Generated TypeScript (buf generate)
  src/
    index.ts                  Re-exports all service clients

packages/api-common/          @coda/api-common (shared pagination, RPC utilities)
```

> **Note:** Compliance and Growth domains are co-located in `apps/platform/src/domains/` and share the `@coda/admin-api` proto package in Phase 1. When these domains decompose into separate services, they will get their own API packages (`@coda/compliance-api`, `@coda/growth-api`).

Consumers import only the packages they need. When a domain becomes its own service, the API package is unchanged -- only the ConnectRPC endpoint configuration updates.

---

## Permission Resolution Pipeline

Every permission check follows this 16-step pipeline. Steps are ordered from cheapest/broadest to most expensive/specific. Early termination on any failure.

```
Request arrives
    |
    v
[1] Authenticate (JWT / API key)
    | Fail -> 401 Unauthorized
    v
[2] Resolve tenant context (ResolveTenant RPC)
    | Grass JWT -> application/profile -> tenant mapping
    | Fail -> 403 No tenant context
    v
[3] Check tenant status
    | suspended/deactivated -> 403 Tenant suspended
    v
[4] Check tenant user status
    | invited/suspended/deactivated -> 403 Account not active
    v
[5] Check consent + DPA requirements (Phase 2)
    | effectiveJurisdiction = mostProtective(tenant, user)
    | Missing consent -> 451
    v
[6] Check IP allowlist (Phase 2)
    | IP not in allowlist -> 403 IP not allowed
    v
[7] Check plan feature gating
    | Feature not in tenant's plan -> 403 Feature not available
    v
[8] Check user deny overrides (DENY-WINS -- before super admin)
    | Active deny override -> DENIED (applies to ALL users including super admins)
    v
[9] Check super admin (if applicable)
    | full -> GRANTED (audit logged, continues to step 14)
    | product + non-test tenant -> DENIED
    | analytics -> DENIED (must use aggregation layer)
    v
[10] Check user direct grants
     | Active, non-expired grant override -> continue to conditions
     v
[11] Resolve role permissions
     | Collect all roles -> resolve inheritance -> merge permissions
     | Permission not found -> DENIED
     v
[12] Check resource scope (Phase 2)
     | ResourceScope rows not matching -> DENIED
     v
[13] Evaluate policy conditions (ABAC)
     | All conditions must pass (AND logic)
     | Any fails -> DENIED
     v
[14] Check data classification (Phase 2)
     | Resource classified -> check permission suffix -> DENIED if missing
     v
[15] Check step-up requirement (Phase 2)
     | No valid StepUpChallenge -> STEP_UP_REQUIRED
     v
[16] GRANTED

[Always] Audit log (async): record outcome, user, permission, resource, IP
```

Steps 5, 6, 12, 14, and 15 are feature-flagged stubs in Phase 1, controlled by `PIPELINE_STEP_*_ENABLED` env vars.

### Implementation details

The resolver is implemented as a factory function (`createPermissionResolver`) that returns a `PermissionResolver` with a single `resolve(request)` method. Key implementation patterns:

- **Parallel cache reads** -- steps 3 and 4 check cache in parallel, then DB in parallel on miss
- **Combined query optimization** -- step 8 loads ALL active user permissions (deny + grant) in one query; step 10 reuses the grant set from that query
- **Batch role hierarchy** -- BFS discovers role ancestors in one query per depth level, batch-loads all permissions in one query, uses in-memory DFS with cycle detection
- **Super admin sentinel** -- `NOT_SUPER_ADMIN` sentinel cached to avoid repeated DB queries for non-admins

---

## Role Hierarchy

Roles support inheritance via `RoleInheritance` (parent-child edges). Resolution uses BFS to discover all ancestor roles, then batch-loads permissions from all roles in a single query.

```
  superadmin
      |
    admin
    /    \
 analyst  power_user
    |
 contributor
```

Cycle detection prevents infinite loops during hierarchy resolution. Verified by `inv-role-cycle.test.ts`.

---

## ABAC Policy Conditions

Six condition types can be attached to permissions via `PolicyCondition` rows:

| Type             | Parameters                         | Evaluation                                          |
| ---------------- | ---------------------------------- | --------------------------------------------------- |
| `ownership`      | `resourceType`                     | Context `resourceOwnerId` matches `userId`          |
| `department`     | `allowedDepartments[]`             | User's department is in the allowed list            |
| `time_window`    | `startHour`, `endHour`, `timezone` | Current time is within the window                   |
| `resource_state` | `allowedStates[]`                  | Context `resourceState` is in the allowed list      |
| `ip_range`       | `cidrs[]`                          | Request IP matches at least one CIDR                |
| `custom`         | `expression`                       | Evaluated against context map (extensibility point) |

All conditions on a permission are AND-ed. A permission passes only if all attached conditions pass.

---

## Caching Strategy

Redis caches permission data at multiple granularities with varying TTLs:

| Cache Key Pattern                      | TTL   | Invalidation Trigger                     |
| -------------------------------------- | ----- | ---------------------------------------- |
| `perm:{tenantId}:{userId}:effective`   | 30s   | Role assign/revoke, user override change |
| `perm:{tenantId}:{userId}:deny`        | 30s   | Deny override add/remove                 |
| `perm:{tenantId}:role:{roleId}`        | 15min | Role permission change                   |
| `perm:{tenantId}:plan`                 | 1hr   | Plan change                              |
| `perm:{tenantId}:tenant_status`        | 30s   | Tenant status change                     |
| `perm:{tenantId}:{userId}:user_status` | 30s   | User status change                       |
| `perm:superadmin:{userId}`             | 5min  | Super admin grant/revoke                 |
| `perm:{tenantId}:conditions:{perm}`    | 15min | Policy condition change                  |
| `access:{tenantId}:{userId}:groups`    | 30s   | Group membership change                  |
| `access:{tenantId}:dpa_valid`          | 5min  | DPA reference change                     |

**Invariant:** Cache invalidation is synchronous. Write operations (e.g., `AssignRole`) await `DEL` on all affected cache keys before returning success. A stale grant is a security vulnerability; a stale denial is a support ticket. Prefer the support ticket.

Two implementations follow the null-object pattern:

- `RedisPermissionCache` -- production, backed by ioredis
- `NullPermissionCache` -- when `REDIS_URL` is absent; reads return null (forcing DB fallback), writes are no-ops

---

## Audit Log Pipeline

```
RPC handler completes
    |
    v
AuditWriter.write(event)  -- fire-and-forget, never blocks the RPC
    |
    v
Encrypt PII fields (AES-256-GCM per-user key)
    | Derive key: HMAC-SHA256(masterKey, userId)
    | Encrypt: userId, ipAddress, userAgent
    | Write: userIdHash (HMAC, non-reversible) for filtering
    v
Redis LPUSH to "audit:buffer" list (pre-encrypted)
    | Invariant: buffer NEVER contains plaintext PII
    v
AuditDrainer (1s interval)
    | RPOP batch (up to 1000 per tick)
    v
Batch INSERT into AuditLog table
    |
    v
Archival worker (daily, future)
    | Rows older than retention setting
    | -> gzip JSON to S3 (partitioned: tenant/YYYY/MM/DD/)
    | -> DELETE archived rows
```

---

## Event Bus

Inter-domain communication uses a typed in-process event bus (`EventEmitter`). Key events:

| Event                      | Publisher | Consumer             | Purpose                                     |
| -------------------------- | --------- | -------------------- | ------------------------------------------- |
| `tenant.status.changed`    | Platform  | Access               | Invalidate cached tenant status             |
| `user.status.changed`      | Platform  | Access               | Invalidate cached user status               |
| `user.suspended`           | Platform  | Access               | Revoke all sessions, flush permission cache |
| `permission.checked`       | Access    | Compliance           | Async audit log entry                       |
| `role.assigned`            | Access    | Compliance           | Permission change audit                     |
| `plan.changed`             | Access    | Platform, Compliance | Update cache, audit log                     |
| `group.membership.changed` | Platform  | Access               | Invalidate group membership cache           |

**Today:** In-process function calls. **Future:** When domains decompose into separate services, the event bus is replaced with Redis Pub/Sub or SQS. The event schema is defined in `events/events.ts`.

---

## Service Discovery

The platform service uses env-var-based service discovery, consistent with the rest of the codebase:

```
PLATFORM_URL=http://platform:8082    # single URL -- all 4 domains in one process
```

Docker Compose resolves service names via built-in DNS. In ECS, values are set to internal ALB or Cloud Map DNS names. When domains decompose, consumers update to domain-specific URLs (e.g., `ACCESS_URL`, `PLATFORM_URL`, `COMPLIANCE_URL`).

API client packages accept the service URL via a factory function with fallback:

```typescript
const accessClient = createAccessClient(
  process.env.ACCESS_URL ?? process.env.PLATFORM_URL,
);
```

---

## Future: Domain Decomposition

When team size warrants, each domain becomes its own ECS Fargate service. Proto package imports remain unchanged -- consumers only update their ConnectRPC endpoint configuration.

```
 ┌──────────────┐  ┌──────────────┐   ┌──────────────┐
 │apps/access   │  │apps/platform │   │apps/compliance│
 │(ConnectRPC)  │  │(ConnectRPC)  │   │(ConnectRPC)   │
 │              │  │              │   │               │
 │AccessService │  │ TenantSvc   │   │ AuditSvc      │
 │ RoleSvc      │  │ IdentitySvc │   │ ConsentSvc    │
 │ PlanSvc      │  │ InviteSvc   │   │ PrivacySvc    │
 │ SessionSvc   │  │ GroupSvc    │   │               │
 └──────────────┘  └──────────────┘   └───────────────┘
```

The in-process event bus is replaced with Redis Pub/Sub or SQS. Direct RPC calls between domains (cold-path only) become ConnectRPC calls over the network. The hot path (permission resolution) reads only local data -- no cross-domain calls.

---

## Configuration Reference

Key environment variables. See the [platform README](../../apps/platform/README.md) for the full list.

| Group              | Variables                                                                                            | Notes                                             |
| ------------------ | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| **Server**         | `PORT`, `ENVIRONMENT`, `SENTRY_DSN`, `SHUTDOWN_TIMEOUT_MS`                                           | Port defaults to 8082                             |
| **Database**       | `CODA_DB_DRIVER`, `CODA_DB_HOST`, `CODA_DB_PORT`, `CODA_DB_USER`, `CODA_DB_PASS`, `CODA_DB_DATABASE` | Shared Aurora MySQL                               |
| **Redis**          | `REDIS_URL`                                                                                          | Shared ElastiCache. Omit for NullPermissionCache. |
| **Pipeline flags** | `PIPELINE_STEP_5_ENABLED` through `PIPELINE_STEP_15_ENABLED`                                         | Phase 1 stubs, all default to false               |
| **Shadow mode**    | `SHADOW_MODE`                                                                                        | Default true. Log-only permission checks.         |
| **Audit**          | `AUDIT_MASTER_KEY`, `KMS_KEY_ARN`                                                                    | Master key required in production.                |

---

## Decisions & Tradeoffs

| Decision                               | Rationale                                                                                                          |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| **ConnectRPC over REST**               | Same port serves both protocols; strongly typed; streaming-ready. Consistent with the search service.              |
| **4 domains in 1 process**             | Simpler deployment today. Proto package split ensures decomposition later is routing, not rewriting.               |
| **Redis cache with DB fallback**       | Permission checks need <5ms p99 cached. DB fallback ensures correctness when cache is unavailable.                 |
| **Synchronous cache invalidation**     | Stale grants are security vulnerabilities. Accept the latency cost of synchronous `DEL` on writes.                 |
| **Crypto-shredding over row deletion** | Reconciles immutable audit logs (SOC 2) with GDPR erasure rights. Structural data preserved, PII destroyed.        |
| **Shadow mode for rollout**            | Enables validation against real traffic before enforcement. Prevents misconfiguration from locking out users.      |
| **Null-object cache pattern**          | `NullPermissionCache` avoids conditional `if (cache)` checks throughout the pipeline. Clean testing without Redis. |
| **Feature-flagged pipeline steps**     | Steps 5, 6, 12, 14, 15 are stubs in Phase 1. Progressive enablement avoids big-bang risk.                          |
