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
| # | Threat | Likelihood | Impact | Mitigation |
|---|---|---|---|---|
| T1 | Cross-tenant data leak via RLS bypass | Low | Catastrophic | Multi-layer isolation (§4); two-tenant test suite in CI |
| T2 | Role escalation by malicious tenant admin | Medium | High | System roles immutable; ABAC predicates enforced; permission registry allowlist |
| T3 | Invite link forgery / replay | Medium | High | Signed tokens with short TTL; single-use; stored hash only |
| T4 | SSO metadata injection (SAML XXE, OIDC discovery spoof) | Medium | High | XML parser with external entities disabled; discovery URL allowlist; metadata integrity check |
| T5 | ABAC DSL injection via user input | Medium | High | Strict DSL schema validation; no eval of raw strings; whitelist operators |
| T6 | Dynamic group query DoS (expensive evaluations) | High | Medium | Pagination; execution budget (max 10s); rate limit 10 eval/tenant/min |
| T7 | Authz cache poisoning | Low | High | Cache key includes JWT kid + permission hash; per-tenant namespacing |
| T8 | Feature-flag tampering to unlock paid features | Medium | Medium | Overrides require org_owner or platform_admin; audit every change |
| T9 | Data residency violation (data written to wrong region) | Low | Catastrophic (GDPR) | Region pinning at connection pool; residency-aware event routing |
| T10 | Privilege-preserving user removal (ghost admin) | Low | Medium | Cascade role unassignment; emit events; background verification sweep |
| T11 | Mass invite abuse (spam vector) | High | Medium | Rate limits per tenant + per admin; AI abuse classifier; domain reputation |
| T12 | Last-owner removal locks tenant | Medium | High | Domain 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:
- Signature verification with public key fetched from JWKS endpoint (
/.well-known/jwks.json), cached 15 min. - Issuer + audience validation (
iss = "https://identity.ghasi-edtech.com",aud = "ghasi-platform"). - Expiry check (access JWT TTL 15 min; reject expired).
kidcheck against current active signing keys.- Actor type:
user,service_account, orapi_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
| Endpoint | Required role |
|---|---|
POST /tenants | platform_admin or unauthenticated (signup flow) |
PATCH /tenants/{id} | org_owner |
PATCH /tenants/{id}/settings | org_owner, org_admin |
POST /tenants/{id}/suspend | platform_admin |
POST /tenants/{id}/memberships | org_owner, org_admin |
PATCH /tenants/{id}/memberships/{userId} | org_owner, org_admin |
DELETE /tenants/{id}/memberships/{userId} | org_owner |
POST /tenants/{id}/roles | org_owner |
PATCH /tenants/{id}/roles/{roleId} | org_owner |
POST /tenants/{id}/org-units | org_owner, org_admin |
POST /tenants/{id}/dynamic-groups | org_owner, org_admin |
PUT /tenants/{id}/feature-flags/{flag} | org_owner, platform_admin |
POST /tenants/{id}/sso/providers | org_owner |
POST /tenants/{id}/data-residency/migrate | platform_admin |
POST /authz/check | Any authenticated (or s2s) |
GET /me/tenants | Any authenticated (no tenant context) |
4. Multi-Tenant Isolation (Multi-Layer)
Per platform spec (13 §4):
| Layer | Mechanism | Verification |
|---|---|---|
| Edge | Per-tenant domain or path prefix | Edge logs audited |
| API Gateway | JWT tid ↔ X-Tenant-Id header match; reject mismatch | Gateway integration test |
| Application | Every use case requires TenantId; cross-tenant refs rejected at construction | Unit tests on domain factories |
| Domain | TenantId VO on every aggregate root; invariants reject cross-tenant | Invariant unit tests |
| Postgres (application set) | SET LOCAL app.tenant_id = $1 on every transaction | PgBouncer init verification |
| Postgres (RLS) | USING (tenant_id = current_setting('app.tenant_id')::uuid) on every table | Two-tenant isolation test in CI |
| Redis | Key prefix tenants/{tid}/... | Cache adapter unit test |
| NATS | tenantId in every envelope; subject-level ACLs for largest tenants | Integration test |
| Logs/Metrics | tenant_id on every signal; tenant-scoped Grafana folders | Observability 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 element | Classification | At-rest | In-transit |
|---|---|---|---|
| Tenant name, slug | Internal | AES-256 (shared KMS) | TLS 1.3 |
| Org unit names | Internal | AES-256 | TLS 1.3 |
| Member email | Confidential | AES-256 + per-tenant DEK | TLS 1.3 + mTLS internal |
| Invite tokens | Restricted | Only hash stored; raw token in email only | TLS 1.3 |
| SSO client secrets | Restricted | Envelope encrypted (KMS DEK per tenant) | TLS 1.3 + mTLS |
| Feature flag reasons | Internal | AES-256 | TLS 1.3 |
| Authz decision audit | Confidential | AES-256 + per-tenant DEK | TLS 1.3 + mTLS |
6. Secret Management
| Secret | Storage | Rotation |
|---|---|---|
| Postgres password | HashiCorp Vault / cloud secret manager | 90 days automated |
| NATS credentials | Vault | 90 days automated |
| JWT signing public keys (JWKS) | Fetched from identity-service | Hourly refresh |
| SSO client secrets | Envelope encrypted in DB; DEK from KMS | Per-tenant; UI-triggered |
| Invite token signing key | KMS-backed | 30 days |
| Redis auth | Vault | 90 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 class | Audit path |
|---|---|
| Membership changes | audit.tenant.membership stream (regulated, 7 years) |
| Role/permission changes | audit.tenant.role stream (regulated, 7 years) |
| Feature flag changes | audit.tenant.feature_flag stream (operational, 13 months) |
| Authz decisions (denied or sampled) | authz_decisions_audit table + ClickHouse sink |
| SSO configuration changes | audit.tenant.sso stream (regulated, 7 years) |
| Data residency migrations | audit.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
| Endpoint | Limit |
|---|---|
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}/evaluate | 10/min per tenant |
POST /authz/check | 1000/sec per service instance (cached heavily) |
| All other writes | 100/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 family | ASVS ref | Status at M3 |
|---|---|---|
| Architecture | V1 | ✓ |
| Authentication (delegated to identity) | V2 | ✓ (by reference) |
| Session mgmt (delegated to identity) | V3 | ✓ |
| Access control | V4 | ✓ ABAC + RBAC enforced |
| Validation/sanitization | V5 | ✓ Zod + Ajv |
| Crypto | V6 | ✓ KMS + envelope encryption |
| Error handling / logging | V7 | ✓ Problem+JSON; PII scrubbed |
| Data protection | V8 | ✓ Classification per column |
| Communications | V9 | ✓ TLS 1.3 + mTLS internal |
| Config | V14 | ✓ No secrets in env; CSP; security headers |
12. Pen-Test Milestones
| Milestone | Scope | Close-out |
|---|---|---|
| M1 (L3 gate) | Tenant provisioning, invite, role assign, authz check | All findings ≥ HIGH closed |
| M3 (L4 gate) | Custom roles, ABAC DSL, SSO config, dynamic groups | All findings ≥ HIGH closed; MEDIUM ≥ 80% closed |
| M5 | Data residency migration saga | All findings ≥ HIGH closed |
13. GDPR & Compliance
- Right to erasure: Membership PII anonymized on
gdpr.subject_request.received.v1withrequestType=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:
homeRegionenforced 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.