tenant-service — SECURITY_MODEL
Companion: DOMAIN_MODEL · API_CONTRACTS · APPLICATION_LOGIC · Platform: 07 Security, Compliance & Tenancy · ADR-0002 Multi-tenancy
tenant-service is the policy decision authority for the whole platform. Every other service consults it (directly via POST /authz/check or indirectly via the cached membership snapshot) before allowing actions on tenant-scoped resources. Its threat model is therefore broader than its surface area.
1. Threat Model (STRIDE Summary)
| Threat | Vector | Primary control |
|---|---|---|
| Spoofing | Forged X-Tenant-Id from a member of a different tenant | JWT tid claim is canonical; gateway rejects mismatched headers; audit row written |
| Tampering | Modified payload in flight | TLS 1.3 only; mTLS between services on the internal mesh |
| Repudiation | Operator denies they performed an action | Append-only audit_events with actor.user_id, request.id, trace.id; BigQuery sink with object-lock |
| Info Disclosure | Cross-tenant read leak | RLS on every child table + two-tenant simulator in CI; PDP returns denied (not 404) for cross-tenant reads to avoid existence oracles |
| DoS | Invitation-acceptance brute force | Per-IP + per-invitation rate limit; constant-time hash compare; CAPTCHA fallback after threshold |
| Elevation of Privilege | Member assigning a role they do not hold | RoleEscalationGuard in domain + ABAC at PDP; "last-owner" protection prevents lockout-by-removal |
2. Authentication
tenant-service does not authenticate end users; it trusts JWTs minted by iam-service. Token validation per request:
- Verify signature with cached JWKS (refresh every 10 min).
- Verify
iss,aud = tenant-service,exp,nbf,tid(tenant id),sub(user id). - Verify
device_idclaim if request originated from a desktop pair. - Verify
acr(auth context reference) for sensitive operations:urn:melmastoon:acr:mfa-recentrequired for tenant suspension, deletion, role changes ontenant.owner.
Service-to-service calls use workload identity federation + mTLS. The Authorization: Bearer header carries a workload JWT verified the same way.
Failure codes: MELMASTOON.IDENTITY.TOKEN_EXPIRED, MELMASTOON.IDENTITY.MFA_REQUIRED, MELMASTOON.AUTH.TENANT_MISMATCH.
3. Authorization (RBAC + ABAC)
3.1 RBAC matrix (system roles)
| Role code | Tenant CRUD | Config edit | Membership CRUD | Role catalog | Invitation | Org tree | Feature flags | Billing contact | PDP check |
|---|---|---|---|---|---|---|---|---|---|
platform.super_admin | full | full | full | full (system) | full | full | full | full | full |
platform.support | read (audited) | read | read | read | read | read | read | read | read |
tenant.owner | read self | full | full (except last-owner remove) | manage custom | full | full | full | full | full |
tenant.gm | read self | full | full (no owner role mgmt) | read | full | full (no move) | read | read | full |
tenant.front_desk | read self | read | read self | read | none | read | read | none | full |
tenant.housekeeping_lead | read self | read | read scoped | read | none | read | read | none | full |
tenant.housekeeping | read self | read | read self | read | none | read | read | none | self only |
tenant.maintenance | read self | read | read self | read | none | read | read | none | self only |
tenant.finance | read self | read | read | read | none | read | read | full | full |
tenant.marketing | read self | read | read | read | none | read | read | none | self only |
chain.operator | read scoped tenants | full (per-tenant) | full (per-tenant) | manage (per-tenant) | full | full | full | full | full |
"Read self" = the membership rows where userId = caller.
3.2 ABAC predicates
The PDP composes RBAC + ABAC. ABAC predicates expressed against the canonical (resource, action) pair:
type AbacRule = {
resource: string; // e.g. 'reservation'
action: string; // e.g. 'check_in'
predicate: (ctx: AuthzContext) => boolean;
};
const SAMPLE_RULES: AbacRule[] = [
// Front desk staff may only act on reservations at properties in their scope
{ resource: 'reservation', action: 'check_in',
predicate: ctx => ctx.resource.propertyId !== undefined &&
ctx.principal.propertyScope.includes(ctx.resource.propertyId) },
// Refunds above 100k AFN require a step-up MFA in the last 5 min
{ resource: 'folio', action: 'refund',
predicate: ctx => ctx.resource.amountMicro < 100_000_000_000n || ctx.context.stepUpRecent === true },
// Suspended tenants accept zero writes (gateway level guard)
{ resource: '*', action: '*',
predicate: ctx => ctx.principal.tenantStatus !== 'suspended' || ctx.action.startsWith('billing.') },
];
Decision algorithm: allow ⇔ ∃ role with permission (resource, action) ∧ ∀ matching ABAC rule predicate evaluates true. Otherwise deny with a reason code.
3.3 Role escalation prevention
RoleEscalationGuard runs on every InviteStaff, AssignRole, and Role.create/update use case:
const actorPermissions = pdp.permittedActions(actor);
for (const p of role.permissions) {
if (!actorPermissions.includes(p)) throw new RoleEscalationError(p);
}
Specifically: a tenant.gm cannot grant tenant.owner (because owner permissions include tenant:close which gm does not hold). System roles are immutable per tenant; only platform admins can evolve their permission set, and only via migration.
3.4 Last-owner protection
OwnerProtectionService.assertNotLastOwner(membership) runs before RemoveMembership and DELETE /role-assignments/{id}:
const owners = await assignmentRepo.countActiveByRoleCode(tenant, 'tenant.owner');
if (owners <= 1 && targetIsOwner(membership)) throw new LastOwnerRemovalError();
This is enforced in the same transaction as the removal so concurrent requests cannot race.
4. Multi-Tenant Isolation
Per ADR-0002:
| Layer | Mechanism |
|---|---|
| Network | Cloud Run + Internal LB; Pub/Sub topics tenant-namespaced; egress restricted via VPC-SC |
| App | NestJS interceptor sets app.tenant_id per request; AsyncLocalStorage carries TenantContext everywhere |
| DB | RLS policy tenant_id = current_setting('app.tenant_id') on every child table |
| Cache | Memorystore keys prefixed t:<tenantId>:…; per-tenant rate-limit buckets |
| Logs | Tenant id added to every log via OTel baggage; PII scrubber strips emails before write |
Two-tenant simulator (mandatory CI gate): for every PR, the integration suite spins two tenants, performs paired writes, and asserts that querying as tenant A returns zero rows from tenant B across every table. See TESTING_STRATEGY §3.1.
5. Data Classification
| Data | Class | Storage | Notes |
|---|---|---|---|
| Tenant name, slug, country | Internal | Cloud SQL, replicated | Not PII |
BillingContact.email, phone, address | PII | CMEK + column encryption | Not exposed to non-finance roles |
BillingContact.tax_id_enc | PII / financial | pgcrypto column encryption | Decrypted only in BillingContact aggregate boundary |
Invitation.email | PII | CMEK; redacted in logs (em_<hash8>) | Cleared on accept (set to <redacted>) |
audit_events.before/after | mixed | CMEK | PII filter strips known fields before write |
tenants.legal_name | Internal | Cloud SQL | May appear in customer-facing receipts |
No guest data lives in tenant-service. The tenant.guest.erasure_requested.v1 event is a fan-out trigger to services that do hold guest data.
6. Secrets Management
| Secret | Store | Rotation |
|---|---|---|
| Postgres password | Secret Manager (CMEK) | 90 d, automated rolling restart |
| Pub/Sub publisher key | Workload Identity (no static secret) | n/a |
| Invitation HMAC pepper (legacy) | Secret Manager | 365 d |
| AI orchestrator API token | Secret Manager | 90 d |
pgcrypto symmetric key (billing_contacts.tax_id_enc) | Secret Manager | 365 d, dual-key rolling read window |
No secrets in env vars at runtime; loaded at boot via Secret Manager SDK. Logs scrub anything matching common secret patterns (Bearer …, eyJ…, hex > 32).
7. Invitation Token Security
- Token =
crypto.randomBytes(32)→ base64url; 64 effective bits of entropy not enough — we keep the full 256 bits, ~43 chars. - Only
sha256(token)is persisted ininvitations.token_hash. - Comparison uses
crypto.timingSafeEqual. - TTL 14 days (configurable per env, never > 30 d).
- Single use:
acceptflipsstatustoacceptedin the same transaction as the membership insert. - Replay attempts on accepted/expired/revoked invitations return
409 MELMASTOON.TENANT.INVITATION_REUSED(or…EXPIRED/…REVOKED) with no oracle for token validity. - Per-IP rate limit: 10 attempts / 5 min; per-invitation: 5 attempts ever.
- Plaintext token never appears in
invitation.sent.v1event payload — it is delivered tonotification-serviceover a 60 s TTL ephemeral side-channel.
8. Audit Logging
Every command writes a row to audit_events in the same transaction as the domain mutation:
| Field | Example |
|---|---|
tenant_id | tnt_… |
actor_user_id | usr_… (null if system / saga) |
action | tenant.suspend, membership.role_change, invitation.accept |
subject_type / subject_id | Membership / mbr_… |
before / after | JSON snapshots, PII-scrubbed |
request_id / trace_id | for joinability |
Long-term store: audit-service consumes melmastoon.tenant.* events and sinks to BigQuery with object-lock on the underlying GCS bucket (Compliance Mode, 7-year retention).
9. Input Validation
- Every controller uses Zod schemas (
@melmastoon/tenant-contracts) bound to OpenAPI. - IDs validated against ULID regex with prefix.
- All free text trimmed + length-limited (legal name 256, display name 128, address line 256).
- Email normalized via
mailparserand lowercased. - All time strings validated against
HH:mmor ISO-8601 with offset. - All SQL goes through parameterized queries; raw SQL forbidden by lint rule.
10. Rate Limiting
| Surface | Bucket | Limit |
|---|---|---|
POST /invitations | per tenant | 50/h, 200/d |
POST /invitations | per actor user | 30/h |
POST /invitations/{id}/accept | per IP | 10 / 5 min |
POST /invitations/{id}/accept | per invitationId | 5 attempts ever |
POST /authz/check | per service caller | 20 000 rps (soft); 30 000 (hard) |
PATCH /tenants/{id}/config | per tenant | 10/min |
| Anonymous (acceptance landing) | per IP | 30 / min |
Buckets implemented via Memorystore INCRBY with sliding window. Over-limit returns 429 MELMASTOON.GENERAL.RATE_LIMITED with Retry-After.
11. OWASP ASVS Coverage
Target: L2 by v1.0; L3 by v1.5 (chain customers).
- V1 Architecture: layered domain isolation, threat model maintained.
- V2 Authentication: delegated to iam-service.
- V3 Session: stateless JWT; revocation via membership removal.
- V4 Access Control: dual RBAC + ABAC; tested in CI matrix.
- V5 Validation: Zod, parameterized SQL.
- V7 Cryptography: CMEK, pgcrypto, TLS 1.3 only.
- V8 Data Protection: PII inventory, CMEK, audit trail.
- V9 Communication: mTLS internal.
- V10 Malicious Code: Binary Authorization on Cloud Run; SBOM signed.
- V11 Business Logic: last-owner, escalation, replay all covered by negative tests.
- V13 API: full OpenAPI; cross-tenant tests; PDP fail-closed.
Pen-test schedule: external test before each major release; internal red team quarterly.
12. Compliance Considerations
| Regulation | Relevance | tenant-service contribution |
|---|---|---|
| GDPR | Operators in EU/EEA | DSAR endpoint + guest.erasure_requested.v1 fan-out; right-to-export through audit + config snapshot |
| Pakistan PECA / Saudi PDPL / UAE PDPL | Regional operators | Residency pin per tenant; data never leaves region |
| PCI DSS | n/a directly | No PAN data here; carve-out lives in payment-gateway-service |
| SOC 2 Type II | Platform | Audit trail, change control, access reviews on platform admin role |
13. Security Review Cadence
- Threat model reviewed every release cycle.
- ABAC rule changes require security-reviewer approval.
- Role catalog seed evolution requires migration + reviewer sign-off.
- Pen-test findings tracked in
SERVICE_RISK_REGISTER; severity ≥ High blocks release.