A complete, regulator-grade implementation blueprint
This document specifies an end-to-end, enterprise-grade AuthN/Z stack for a regulated, multi-tenant payments system. It emphasizes defense-in-depth, tenant isolation, provable policy correctness, auditor-ready evidence, and latency budgets compatible with high-throughput ledgers and payment rails.
Core pillars
ext_authz at the edge, partial evaluation for p99 < 5ms.Tenant (e.g., Merchant of Record, Institution, Region).Headers: x-req-id, x-tenant-id, x-principal-id, x-session-id, x-client-ip, x-device-id.
JWT claims:
sub, tid, jti, iat/exp, aud, iss,aal (Authenticator Assurance Level), amr[] (methods),scope[], org_roles[], tenant_roles[{tenant, roles[]}],cnf (confirmation / key binding for DPoP or mTLS).aal>=2.Access tokens: JWT, 5–10 min, audience-scoped (per service).
Refresh tokens: opaque, rotating, stored server-side; theft ⇒ single-use rotation detects replay.
Binding:
Bound step-up (prevents replay across endpoints)
// On protected action with insufficient AAL:
const origHash = base64url(sha256(`${method}|${path}|${normalizedBody}`));
return 403, {
error: "MFA_REQUIRED",
challenge: signJWT({ origHash, tid, sub, exp: now+300 }) // short-lived
};
// /mfa/complete validates factor + challenge:
assert verify(challengeJWT) && challenge.origHash == recompute();
return accessToken({ aal: 2, cnf: { orig: origHash }, aud, exp: now+10*60 });
OIDC/SAML per tenant:
saml_idp_metadata_url, oidc_issuer, client_id, redirect_uris.SCIM 2.0: /Users, /Groups → map to internal principals and ReBAC tuples.
Attribute mapping: IdP groups → roles; time-boxed caveats for contractors.
ext_authz) for all HTTP/gRPC requests.CREATE TABLE rel_tuples (
subject_ns text, -- user | role | service | tenant
subject_id text,
relation text, -- owner | admin | member | editor | viewer | parent | ...
object_ns text, -- tenant | merchant | account | payout | ...
object_id text,
caveat jsonb, -- { "expires_at": "...", "hours":[9,17], "ip_ranges":["203.0.113.0/24"] }
PRIMARY KEY (subject_ns, subject_id, relation, object_ns, object_id)
);
CREATE INDEX ON rel_tuples (object_ns, object_id, relation);
CREATE INDEX ON rel_tuples USING gin (caveat);
{
"tenant": { "id": "acme", "security": {"mfa_required": true} },
"subject": { "id": "user_123", "type": "user", "roles": ["ops"], "aal": 1 },
"resource": { "type": "merchant", "id": "m_789", "tenant_id": "acme", "attrs": {"region":"KE"} },
"action": "settlement.update",
"purpose": "reconciliation",
"context": { "ip":"203.0.113.5", "ua":"...", "risk":"high", "time":"2025-09-22T09:10:00Z" }
}
package payments.authz
default allow := false
default step_up_required := false
same_tenant { input.resource.tenant_id == input.tenant.id }
# Purpose-based access control (PBAC)
purpose_allowed {
some p
data.purposes[p].name == input.purpose
data.purposes[p].resources[_] == input.resource.type
data.purposes[p].actions[_] == input.action
}
# ReBAC: subject related to object (direct or via parent)
related(subject, object, rel) {
some i
data.relationships[i].subject_ns == subject.type
data.relationships[i].subject_id == subject.id
data.relationships[i].relation == rel
data.relationships[i].object_ns == object.type
data.relationships[i].object_id == object.id
not expired(data.relationships[i])
}
expired(t) { t.caveat.expires_at != "" ; time.now_ns() > time.parse_ns_rfc3339(t.caveat.expires_at) }
# High-risk signals
step_up_required { input.action == "settlement.update" } else { input.context.risk == "high" }
# Allow if:
allow {
same_tenant
purpose_allowed
related({"id": input.subject.id, "type": "user"},
{"id": input.resource.id, "type": input.resource.type},
"editor")
not step_up_required
}
# Allow with AAL2 if step-up needed:
allow { same_tenant; purpose_allowed; step_up_required; input.subject.aal >= 2 }
-- Set at connection per request
-- SET app.current_tenant = '<tid>';
ALTER TABLE merchants ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON merchants
USING (tenant_id = current_setting('app.current_tenant', true));
-- Optionally: fine-grained role mirror (if needed)
ak_live_xxx) and secret (shown once).function hmacSignature({method, path, body, date, nonce}: any, secret: Buffer) {
const payload = [method.toUpperCase(), path, sha256(body), date, nonce].join("\n");
return base64url(hmacSha256(secret, payload));
}
BUDGET_EXCEEDED.CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
ts timestamptz NOT NULL DEFAULT now(),
tenant_id text NOT NULL,
actor jsonb NOT NULL, -- {id,type,aal,session_id}
action text NOT NULL, -- "settlement.update"
target jsonb NOT NULL, -- {type,id}
decision jsonb NOT NULL, -- {allow, policy_version, trace_id}
attrs jsonb, -- IP, UA, risk, purpose, mfa_used
prev_hash bytea,
row_hash bytea
);
| Domain | Control | Mechanism |
|---|---|---|
| PCI DSS 7.x | Access control | ReBAC+OPA, RLS, least privilege, step-up |
| PCI DSS 10.x | Logging | Hash-chain audit, WORM export |
| PSD2/SCA | Strong auth | WebAuthn/TOTP step-up; AAL2 thresholds |
| GDPR/NDPA | Purpose limitation | PBAC + purpose registry; minimization; field-level crypto |
| ISO 27001 | Policy management | Signed bundles, reviews, change control |
| SOC 2 | Evidence | Automated reports (MFA rates, revocation SLAs, decision latency) |
ext_authz calls centralized OPA for allow/deny before routing.active|grace|retired, max 24h overlap).Envoy excerpt
http_filters:
- name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
grpc_service: { envoy_grpc: { cluster_name: authz } }
with_request_body: { max_request_bytes: 8192, allow_partial_message: true }
include_peer_certificate: true
opa build -t eval -e payments.authz/allow -o bundle.tar.gz policy/ data//v1/auth/simulate?policy_ref=refs/heads/next accepts input doc, returns allow/deny + explain trace.security_settings JSONB): mfa_required_for_all_users, session_timeout_minutes, allowed_ip_ranges, resident_regions, require_dpop, require_mtls_for_m2m.sessions (Redis JSON)
{
"jti":"sess_abc", "sub":"user_123", "tid":"acme",
"aal":1, "device":"browser:chrome", "ip":"203.0.113.5",
"created_at":"2025-09-22T09:00:00Z", "last_seen":"2025-09-22T09:05:12Z",
"revoked_at":null
}
api_keys (SQL)
CREATE TABLE api_keys (
id text PRIMARY KEY, -- ak_live_xxx
tenant_id text NOT NULL,
secret_hash text NOT NULL, -- argon2id
scopes text[] NOT NULL,
budget jsonb, -- {amount_daily: 1000000, currency:"KES"}
ip_allowlist cidr[],
expires_at timestamptz,
created_by text, created_at timestamptz default now(), revoked_at timestamptz
);
tenants.security_settings (JSONB)
{
"mfa_required_for_all_users": true,
"session_timeout_minutes": 30,
"allowed_ip_ranges": ["196.201.0.0/16"],
"resident_regions": ["KE","UG"],
"require_dpop": true,
"require_mtls_for_m2m": true
}
POST /auth/login → WebAuthn begin/finish or OIDC callback → access & refresh tokens.POST /auth/token → refresh (rotate), DPoP proof validation.POST /auth/mfa/challenge → produce bound challenge on 403.POST /auth/mfa/complete → exchange for AAL2 token (bound).GET /sessions?userId=... / DELETE /sessions/{id} → admin visibility/revocation.POST /api-keys / DELETE /api-keys/{id} → create/rotate/revoke; show secret once.POST /authz/decision (internal) → Envoy ext_authz integration.POST /authz/simulate?policy_ref=... → dry-run with explain.POST /scim/v2/Users, PATCH /scim/v2/Users/{id}, DELETE /scim/v2/Users/{id}.Key compromise suspected:
sess:*), rotate signing keys (per-tenant JWKS), invalidate refresh tokens, notify tenants.Deny storm:
MFA provider outage:
Region isolation:
Weeks 0–2
ext_authz + centralized OPA; decision logging to secure sink.Weeks 3–6
Weeks 7–9
Weeks 10–12
TypeScript: request identity extractor
export type AuthzInput = {
tenant: { id: string; security: Record<string, unknown> };
subject: { id: string; type: "user"|"service"; roles: string[]; aal: number };
resource: { type: string; id: string; tenant_id: string; attrs?: Record<string, unknown> };
action: string;
purpose: string;
context: { ip: string; ua: string; risk: string; time: string };
};
export function toAuthzInput(req: any): AuthzInput {
return {
tenant: { id: req.headers["x-tenant-id"], security: req.tenantSecurity },
subject: { id: req.user.sub, type: req.user.typ, roles: req.user.roles ?? [], aal: req.user.aal ?? 1 },
resource: req.resourceDescriptor, // set by router/resource middleware
action: req.action, // e.g., "settlement.update"
purpose: req.headers["x-purpose"] ?? "operational",
context: { ip: req.ip, ua: req.headers["user-agent"], risk: req.risk, time: new Date().toISOString() }
};
}
Node: API key verification with budgets
async function verifyApiKey(req) {
const id = req.get("x-api-key-id");
const sig = req.get("x-signature");
const nonce = req.get("x-nonce");
const date = req.get("date");
const rec = await db.api_keys.findByPk(id);
if (!rec || rec.revoked_at) throw forbidden("invalid_key");
assertWithin(rec.ip_allowlist, req.ip);
assertNotExpired(rec.expires_at);
const ok = await argon2Verify(rec.secret_hash, req.get("x-api-key-secret") ?? ""); // or use HMAC only
if (!ok) throw forbidden("bad_secret");
verifyHmac(sig, {method:req.method,path:req.path,body:req.rawBody,date,nonce}, rec.key_bytes);
await assertBudget(rec, req); // amount/day, txn/min
}
PostgreSQL: per-request tenant scoping
-- At request start:
-- SELECT set_config('app.current_tenant', $1, true);
-- SELECT set_config('app.principal_roles', $2, true); -- optional JSON of role list
ext_authz mandatory; test drift in canaries.ext_authz → OPA live; no bypass routes.This blueprint yields regulator-ready, breach-resilient AuthN/Z: passkeys and bound step-ups, DPoP/mTLS-bound short-lived tokens, centralized OPA with ReBAC and PBAC, plus DB-level RLS as a backstop. Everything is signed, measured, tested, and reversible, with clean audit trails and fast failure modes—exactly what a payments platform needs to scale cross-tenant and cross-region without sacrificing safety or speed.