Skip to main content

Security

:::info Source Sourced from services/tenant-service/SECURITY_MODEL.md in the documentation repo. :::

Blueprint doc 9 of 17. Companion: 13 Security & Tenancy | APPLICATION_LOGIC | DATA_MODEL


1. Threat Model Summary

#ThreatLikelihoodImpactMitigation
T1Cross-tenant data leak via RLS bypassLowCatastrophicMulti-layer isolation (§4); two-tenant test suite in CI
T2Role escalation by malicious tenant adminMediumHighSystem roles immutable; ABAC predicates enforced; permission registry allowlist
T3Invite link forgery / replayMediumHighSigned tokens with short TTL; single-use; stored hash only
T4SSO metadata injection (SAML XXE, OIDC discovery spoof)MediumHighXML parser with external entities disabled; discovery URL allowlist; metadata integrity check
T5ABAC DSL injection via user inputMediumHighStrict DSL schema validation; no eval of raw strings; whitelist operators
T6Dynamic group query DoS (expensive evaluations)HighMediumPagination; execution budget (max 10s); rate limit 10 eval/tenant/min
T7Authz cache poisoningLowHighCache key includes JWT kid + permission hash; per-tenant namespacing
T8Feature-flag tampering to unlock paid featuresMediumMediumOverrides require org_owner or platform_admin; audit every change
T9Data residency violation (data written to wrong region)LowCatastrophic (GDPR)Region pinning at connection pool; residency-aware event routing
T10Privilege-preserving user removal (ghost admin)LowMediumCascade role unassignment; emit events; background verification sweep
T11Mass invite abuse (spam vector)HighMediumRate limits per tenant + per admin; AI abuse classifier; domain reputation
T12Last-owner removal locks tenantMediumHighDomain invariant prevents last-owner removal; self-service recovery via platform support

2. Authentication

Tenant-service does not authenticate users — that is identity-service's responsibility. It trusts JWTs issued by identity-service, validated via:

  1. Signature verification with public key fetched from JWKS endpoint (/.well-known/jwks.json), cached 15 min.
  2. Issuer + audience validation (iss = "https://identity.ghasi-edtech.com", aud = "ghasi-platform").
  3. Expiry check (access JWT TTL 15 min; reject expired).
  4. kid check against current active signing keys.
  5. Actor type: user, service_account, or api_key.

Service-to-service calls use mTLS (service mesh) + service-account JWTs. The tenant.resolve_for_request NATS reply endpoint accepts only trusted service-account tokens.


3. Authorization

3.1 Policy Decision Point (PDP)

The tenant-service is the PDP for the platform. Every service calls POST /api/v1/authz/check (or consumes cached decisions) before granting access to a resource.

3.2 RBAC + ABAC Evaluation

Request: { tenantId, userId, resource, action, resourceAttributes }


1. Load Membership (tenantId, userId) → roleIds, orgUnitIds
2. Load all permissions for roles
3. Filter: permissions where (resource = req.resource AND action = req.action)
4. For each matching permission:
if condition is null → ALLOW
else: evaluate ABAC predicate(context) → if true, ALLOW
5. If no ALLOW → DENY


Response: { allowed, decisionId, matchedRoles, matchedPermissions }

3.3 Minimum Roles for Tenant-Service Endpoints

EndpointRequired role
POST /tenantsplatform_admin or unauthenticated (signup flow)
PATCH /tenants/{id}org_owner
PATCH /tenants/{id}/settingsorg_owner, org_admin
POST /tenants/{id}/suspendplatform_admin
POST /tenants/{id}/membershipsorg_owner, org_admin
PATCH /tenants/{id}/memberships/{userId}org_owner, org_admin
DELETE /tenants/{id}/memberships/{userId}org_owner
POST /tenants/{id}/rolesorg_owner
PATCH /tenants/{id}/roles/{roleId}org_owner
POST /tenants/{id}/org-unitsorg_owner, org_admin
POST /tenants/{id}/dynamic-groupsorg_owner, org_admin
PUT /tenants/{id}/feature-flags/{flag}org_owner, platform_admin
POST /tenants/{id}/sso/providersorg_owner
POST /tenants/{id}/data-residency/migrateplatform_admin
POST /authz/checkAny authenticated (or s2s)
GET /me/tenantsAny authenticated (no tenant context)

4. Multi-Tenant Isolation (Multi-Layer)

Per platform spec (13 §4):

LayerMechanismVerification
EdgePer-tenant domain or path prefixEdge logs audited
API GatewayJWT tidX-Tenant-Id header match; reject mismatchGateway integration test
ApplicationEvery use case requires TenantId; cross-tenant refs rejected at constructionUnit tests on domain factories
DomainTenantId VO on every aggregate root; invariants reject cross-tenantInvariant unit tests
Postgres (application set)SET LOCAL app.tenant_id = $1 on every transactionPgBouncer init verification
Postgres (RLS)USING (tenant_id = current_setting('app.tenant_id')::uuid) on every tableTwo-tenant isolation test in CI
RedisKey prefix tenants/{tid}/...Cache adapter unit test
NATStenantId in every envelope; subject-level ACLs for largest tenantsIntegration test
Logs/Metricstenant_id on every signal; tenant-scoped Grafana foldersObservability contract test

4.1 Two-Tenant Isolation Test (Mandatory CI Gate)

describe('cross-tenant isolation', () => {
const tenantA = makeTenant();
const tenantB = makeTenant();

test('tenant A cannot read tenant B memberships', async () => {
const memberB = await createMember(tenantB);
await expect(
withTenantContext(tenantA, () => membershipRepo.findById(memberB.id, tenantB.id))
).rejects.toThrow(/not found|forbidden/i);
});

test('tenant A query returns no tenant B rows even with unfiltered SQL', async () => {
// Set tenant A context, then query without any WHERE tenant_id clause
const rows = await withTenantContext(tenantA, () => rawSql('SELECT * FROM tenant.memberships'));
expect(rows.every(r => r.tenant_id === tenantA.id)).toBe(true);
});

test('authz check rejects cross-tenant resource access', async () => {
const resourceB = { tenant_id: tenantB.id };
const decision = await authzCheck({
tenantId: tenantA.id,
userId: tenantA.ownerId,
resource: 'course',
action: 'read',
resourceAttributes: resourceB,
});
expect(decision.allowed).toBe(false);
});
});

5. Data Classification

Data elementClassificationAt-restIn-transit
Tenant name, slugInternalAES-256 (shared KMS)TLS 1.3
Org unit namesInternalAES-256TLS 1.3
Member emailConfidentialAES-256 + per-tenant DEKTLS 1.3 + mTLS internal
Invite tokensRestrictedOnly hash stored; raw token in email onlyTLS 1.3
SSO client secretsRestrictedEnvelope encrypted (KMS DEK per tenant)TLS 1.3 + mTLS
Feature flag reasonsInternalAES-256TLS 1.3
Authz decision auditConfidentialAES-256 + per-tenant DEKTLS 1.3 + mTLS

6. Secret Management

SecretStorageRotation
Postgres passwordHashiCorp Vault / cloud secret manager90 days automated
NATS credentialsVault90 days automated
JWT signing public keys (JWKS)Fetched from identity-serviceHourly refresh
SSO client secretsEnvelope encrypted in DB; DEK from KMSPer-tenant; UI-triggered
Invite token signing keyKMS-backed30 days
Redis authVault90 days automated

No secrets in environment variables in prod. All secrets injected via sidecar (Vault Agent) or workload identity (IRSA/Workload Identity Federation).


7. Invite Token Security

// Token generation
const token = generateSecureRandom(32); // 256 bits
const tokenHash = sha256(token + tenant_salt);
await db.memberships.update(id, { invite_token_hash: tokenHash, invite_expires_at: now + 14_days });
// Send `token` via email; NEVER persist raw

// Token acceptance
const candidate = sha256(req.body.token + tenant_salt);
const membership = await db.memberships.findOne({ invite_token_hash: candidate, invite_expires_at: { $gt: now }, status: 'invited' });
if (!membership) throw new Error('INVITE_NOT_FOUND_OR_EXPIRED');
// Single-use: clear token hash on accept
await db.memberships.update(membership.id, { invite_token_hash: null, status: 'active', joined_at: now });
  • 256-bit entropy; URL-safe base64 encoded (44 chars).
  • Hashed before storage (salt per tenant).
  • 14-day TTL.
  • Single-use (cleared on accept).
  • Constant-time hash comparison to prevent timing attacks.

8. Audit Logging

Every mutation emits a domain event (see EVENT_SCHEMAS.md) that doubles as an audit record. Additional audit records go to the append-only audit log (audit-service):

Event classAudit path
Membership changesaudit.tenant.membership stream (regulated, 7 years)
Role/permission changesaudit.tenant.role stream (regulated, 7 years)
Feature flag changesaudit.tenant.feature_flag stream (operational, 13 months)
Authz decisions (denied or sampled)authz_decisions_audit table + ClickHouse sink
SSO configuration changesaudit.tenant.sso stream (regulated, 7 years)
Data residency migrationsaudit.tenant.residency stream (regulated, 7 years)

Daily Merkle anchoring of audit stream root hashes; tamper detection via daily verification job.


9. Input Validation

All inputs pass through Zod schemas at the controller layer before reaching use cases. Examples:

const ProvisionTenantSchema = z.object({
name: z.string().min(1).max(200),
slug: z.string().regex(/^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/),
type: z.enum(['org', 'provider', 'individual', 'org+provider']),
homeRegion: z.enum(['us', 'eu', 'me', 'ap']),
plan: z.object({ id: z.string(), addons: z.array(z.string()) }),
ownerEmail: z.string().email(),
settings: TenantSettingsSchema.optional(),
});

const ABACQuerySchema: z.ZodType<ABACQuery> = z.lazy(() =>
z.discriminatedUnion('op', [
z.object({ op: z.literal('eq'), field: z.string(), value: JSONValueSchema }),
z.object({ op: z.literal('in'), field: z.string(), values: z.array(JSONValueSchema) }),
z.object({ op: z.literal('and'), conditions: z.array(ABACQuerySchema).max(20) }),
// ... max query depth enforced at 10
])
);

10. Rate Limiting

EndpointLimit
POST /tenants (public signup)5/hour per IP, 100/day per IP
POST /tenants/{id}/memberships (invite)100/hour per tenant, 500/day per tenant
POST /tenants/{id}/dynamic-groups/{gid}/evaluate10/min per tenant
POST /authz/check1000/sec per service instance (cached heavily)
All other writes100/min per user

Enforcement: Redis-backed token bucket at API gateway; service-level fallback.


11. OWASP ASVS Coverage

Target: ASVS L2 at M0; ASVS L3 on auth-adjacent surfaces (SSO config, role management) at M3.

Control familyASVS refStatus at M3
ArchitectureV1
Authentication (delegated to identity)V2✓ (by reference)
Session mgmt (delegated to identity)V3
Access controlV4✓ ABAC + RBAC enforced
Validation/sanitizationV5✓ Zod + Ajv
CryptoV6✓ KMS + envelope encryption
Error handling / loggingV7✓ Problem+JSON; PII scrubbed
Data protectionV8✓ Classification per column
CommunicationsV9✓ TLS 1.3 + mTLS internal
ConfigV14✓ No secrets in env; CSP; security headers

12. Pen-Test Milestones

MilestoneScopeClose-out
M1 (L3 gate)Tenant provisioning, invite, role assign, authz checkAll findings ≥ HIGH closed
M3 (L4 gate)Custom roles, ABAC DSL, SSO config, dynamic groupsAll findings ≥ HIGH closed; MEDIUM ≥ 80% closed
M5Data residency migration sagaAll findings ≥ HIGH closed

13. GDPR & Compliance

  • Right to erasure: Membership PII anonymized on gdpr.subject_request.received.v1 with requestType=erasure; tombstone row retained (anonymized) for audit purposes.
  • Right to access: Export endpoint compiles all tenant-service data for the subject.
  • Right to rectification: PATCH endpoints support field-level correction.
  • Data residency: homeRegion enforced at connection pool + event routing; residency migration saga documented in MIGRATION_PLAN.md.
  • Records of processing: Every tenant entry is a data controller record; audit stream satisfies Article 30.