Skip to main content

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)

ThreatVectorPrimary control
SpoofingForged X-Tenant-Id from a member of a different tenantJWT tid claim is canonical; gateway rejects mismatched headers; audit row written
TamperingModified payload in flightTLS 1.3 only; mTLS between services on the internal mesh
RepudiationOperator denies they performed an actionAppend-only audit_events with actor.user_id, request.id, trace.id; BigQuery sink with object-lock
Info DisclosureCross-tenant read leakRLS on every child table + two-tenant simulator in CI; PDP returns denied (not 404) for cross-tenant reads to avoid existence oracles
DoSInvitation-acceptance brute forcePer-IP + per-invitation rate limit; constant-time hash compare; CAPTCHA fallback after threshold
Elevation of PrivilegeMember assigning a role they do not holdRoleEscalationGuard 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_id claim if request originated from a desktop pair.
  • Verify acr (auth context reference) for sensitive operations: urn:melmastoon:acr:mfa-recent required for tenant suspension, deletion, role changes on tenant.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 codeTenant CRUDConfig editMembership CRUDRole catalogInvitationOrg treeFeature flagsBilling contactPDP check
platform.super_adminfullfullfullfull (system)fullfullfullfullfull
platform.supportread (audited)readreadreadreadreadreadreadread
tenant.ownerread selffullfull (except last-owner remove)manage customfullfullfullfullfull
tenant.gmread selffullfull (no owner role mgmt)readfullfull (no move)readreadfull
tenant.front_deskread selfreadread selfreadnonereadreadnonefull
tenant.housekeeping_leadread selfreadread scopedreadnonereadreadnonefull
tenant.housekeepingread selfreadread selfreadnonereadreadnoneself only
tenant.maintenanceread selfreadread selfreadnonereadreadnoneself only
tenant.financeread selfreadreadreadnonereadreadfullfull
tenant.marketingread selfreadreadreadnonereadreadnoneself only
chain.operatorread scoped tenantsfull (per-tenant)full (per-tenant)manage (per-tenant)fullfullfullfullfull

"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:

LayerMechanism
NetworkCloud Run + Internal LB; Pub/Sub topics tenant-namespaced; egress restricted via VPC-SC
AppNestJS interceptor sets app.tenant_id per request; AsyncLocalStorage carries TenantContext everywhere
DBRLS policy tenant_id = current_setting('app.tenant_id') on every child table
CacheMemorystore keys prefixed t:<tenantId>:…; per-tenant rate-limit buckets
LogsTenant 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

DataClassStorageNotes
Tenant name, slug, countryInternalCloud SQL, replicatedNot PII
BillingContact.email, phone, addressPIICMEK + column encryptionNot exposed to non-finance roles
BillingContact.tax_id_encPII / financialpgcrypto column encryptionDecrypted only in BillingContact aggregate boundary
Invitation.emailPIICMEK; redacted in logs (em_<hash8>)Cleared on accept (set to <redacted>)
audit_events.before/aftermixedCMEKPII filter strips known fields before write
tenants.legal_nameInternalCloud SQLMay 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

SecretStoreRotation
Postgres passwordSecret Manager (CMEK)90 d, automated rolling restart
Pub/Sub publisher keyWorkload Identity (no static secret)n/a
Invitation HMAC pepper (legacy)Secret Manager365 d
AI orchestrator API tokenSecret Manager90 d
pgcrypto symmetric key (billing_contacts.tax_id_enc)Secret Manager365 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 in invitations.token_hash.
  • Comparison uses crypto.timingSafeEqual.
  • TTL 14 days (configurable per env, never > 30 d).
  • Single use: accept flips status to accepted in 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.v1 event payload — it is delivered to notification-service over 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:

FieldExample
tenant_idtnt_…
actor_user_idusr_… (null if system / saga)
actiontenant.suspend, membership.role_change, invitation.accept
subject_type / subject_idMembership / mbr_…
before / afterJSON snapshots, PII-scrubbed
request_id / trace_idfor 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 mailparser and lowercased.
  • All time strings validated against HH:mm or ISO-8601 with offset.
  • All SQL goes through parameterized queries; raw SQL forbidden by lint rule.

10. Rate Limiting

SurfaceBucketLimit
POST /invitationsper tenant50/h, 200/d
POST /invitationsper actor user30/h
POST /invitations/{id}/acceptper IP10 / 5 min
POST /invitations/{id}/acceptper invitationId5 attempts ever
POST /authz/checkper service caller20 000 rps (soft); 30 000 (hard)
PATCH /tenants/{id}/configper tenant10/min
Anonymous (acceptance landing)per IP30 / 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

RegulationRelevancetenant-service contribution
GDPROperators in EU/EEADSAR endpoint + guest.erasure_requested.v1 fan-out; right-to-export through audit + config snapshot
Pakistan PECA / Saudi PDPL / UAE PDPLRegional operatorsResidency pin per tenant; data never leaves region
PCI DSSn/a directlyNo PAN data here; carve-out lives in payment-gateway-service
SOC 2 Type IIPlatformAudit 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.