World-class, regulator-ready design for phone+PIN login with Purpose-Based Access Control (PBAC) — multi-tenant, auditable, and low-latency.
This document specifies an end-to-end implementation for customer authentication (phone + PIN) and authorization with PBAC, centered on the canonical purpose customer.transact (“the customer is moving money”). It integrates with ReBAC/OPA, database Row-Level Security (RLS), risk-adaptive step-up (AAL2), device binding, and tamper-evident auditing.
Outcomes
Assumptions: TypeScript/Node, pnpm, PostgreSQL (+ RLS), Redis, Envoy ext_authz, OPA (policy bundles), multi-tenant architecture.
customer.transact → e.g., transfer.create) with AAL2 step-up and intent signing.Keep a short, hierarchical list (expand only when necessary):
customer.transact — create/confirm transfers, cash-out, pay billscustomer.account.view — balances, history (read-only)customer.beneficiary.manage — add/remove payeessupport.investigate — support agent troubleshooting (masked data)fraud.review — fraud ops (elevated visibility, AAL2)compliance.kyc — KYC & AML checks (strictly gated)analytics.aggregate — aggregates only (no raw PII)marketing.personalize — opt-in only, policy & consent gatedThe star of this doc is
customer.transact.
sequenceDiagram
autonumber
participant Client (Mobile/Web)
participant Edge (Envoy)
participant AuthZ (OPA)
participant API (Customer Service)
participant Redis (Sessions)
participant PG (PostgreSQL)
Client->>Edge: POST /v1/transfers (access JWT, no AAL2 yet)
Edge->>API: forwards + inject Route-Purpose = "customer.transact"
API->>API: Risk eval (IP, device, amount, SIM age) -> risk=med/high?
API->>AuthZ: OPA /decision (subject, resource, action, purpose, aal)
AuthZ-->>API: deny if purpose/action mismatch or AAL<2
API-->>Client: 403 MFA_REQUIRED + bound challenge (orig_req_hash)
Client->>API: POST /auth/stepup/complete (OTP or device-sign)
API->>Redis: upgrade session (aal=2, short ttl)
API->>AuthZ: OPA /decision (aal=2)
AuthZ-->>API: allow
API->>PG: RLS-enforced write (tenant-scoped)
API-->>Client: 200 OK (purpose-shaped response)
Key: Client never controls the purpose; route/action defines it. OPA decides allow/deny using PBAC + ReBAC + AAL, DB enforces RLS as a backstop.
Access JWT (short-lived, 5–10 min)
Claims: sub, tid, jti, iat/exp, aud, aal, amr[], optional cnf (for DPoP/mTLS binding).
Refresh token: opaque, rotating, stored server-side (replay detection).
AAL: 1 = PIN, 2 = step-up (OTP or device biometric), 3 = hardware key (optional future).
Optional: DPoP (browser/mobile) and mTLS-bound tokens (service-to-service).
purposes.json (bundled with OPA data; versioned & signed)
{
"purposes": [
{
"name": "customer.transact",
"min_aal": 2,
"resources": ["transaction", "beneficiary", "limit"],
"actions": ["transfer.create", "transfer.confirm", "cashout.create"],
"field_policies": { "transaction": "full", "beneficiary": "masked" },
"consent_required": false,
"retention_days": 365
},
{
"name": "customer.account.view",
"min_aal": 1,
"resources": ["account", "transaction"],
"actions": ["account.read", "transaction.read"],
"field_policies": { "transaction": "masked", "account": "masked" },
"consent_required": false,
"retention_days": 365
}
],
"version": "2025-09-22T09:00:00Z",
"signature": "cosign-attestation-or-kms-jws"
}
Do not accept client-supplied purposes. Define a deterministic map in your gateway or API router.
export const ROUTE_PURPOSE: Record<string, string> = {
"POST /v1/transfers": "customer.transact",
"POST /v1/transfers/confirm": "customer.transact",
"GET /v1/transactions": "customer.account.view",
"POST /v1/beneficiaries": "customer.beneficiary.manage"
};
export function attachPurpose(req, _res, next) {
const key = `${req.method} ${req.routePath}`;
req.purpose = ROUTE_PURPOSE[key] ?? "operational";
// Block spoofing: overwrite any client header
req.headers["x-purpose"] = req.purpose;
// Optionally set a DB session var for masking views:
req.db.run(`select set_config('app.current_purpose',$1,true)`, [req.purpose]);
next();
}
Input contract (TypeScript → OPA)
export type AuthzInput = {
tenant: { id: string, security?: Record<string, unknown> };
subject: { id: string, type: "customer"|"user"|"service", roles?: string[], aal: number };
resource: { type: string, id?: string, tenant_id: string, attrs?: Record<string, unknown> };
action: string; // e.g., "transfer.create"
purpose: string; // e.g., "customer.transact"
context: { ip: string, ua: string, risk: "low"|"medium"|"high", time: string };
};
Rego policy (excerpt)
package payments.authz
default allow := false
default step_up_required := false
same_tenant { input.resource.tenant_id == input.tenant.id }
purpose_allowed {
some p
data.purposes[p].name == input.purpose
data.purposes[p].resources[_] == input.resource.type
data.purposes[p].actions[_] == input.action
}
aal_sufficient {
some p
data.purposes[p].name == input.purpose
input.subject.aal >= data.purposes[p].min_aal
}
# ReBAC relation (e.g., customer owns the resource context)
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 → step-up
step_up_required { input.purpose == "customer.transact" } else { input.context.risk == "high" }
# Allow if tenant OK, purpose OK, relation OK, and AAL is sufficient
allow { same_tenant; purpose_allowed; related(input.subject, {"type": "tenant", "id": input.tenant.id}, "member"); aal_sufficient }
# Or allow with step-up satisfied
allow { same_tenant; purpose_allowed; related(input.subject, {"type":"tenant","id":input.tenant.id}, "member"); step_up_required; input.subject.aal >= 2 }
Deploy as signed bundles (cosign/KMS). Use partial evaluation and in-memory data for p99 < 5 ms.
ALTER TABLE transactions ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_txn ON transactions
USING (tenant_id = current_setting('app.current_tenant', true));
Set app.current_tenant per request (connection/session variable) in the API.
Server projection
const FIELD_POLICIES = {
full: (t: any) => t,
masked: (t: any) => ({ ...t, name: maskName(t.name), phone: maskPhone(t.phone), pan_suffix: t.pan_suffix, pan_full: undefined })
};
export function shapeForPurpose(entity: any, entityType: "transaction"|"beneficiary", purpose: string, registry: Registry) {
const rule = registry.fieldPolicy(purpose, entityType); // "full"|"masked"|...
return FIELD_POLICIES[rule](entity);
}
DB view with masking
CREATE FUNCTION mask_phone(p text) RETURNS text AS $$
SELECT CASE WHEN p IS NULL THEN NULL ELSE substr(p,1,4)||'******'||substr(p, length(p)-1, 2) END;
$$ LANGUAGE sql IMMUTABLE;
CREATE VIEW v_transactions AS
SELECT
t.id, t.tenant_id, t.amount, t.currency,
CASE WHEN current_setting('app.current_purpose', true) IN ('support.investigate','customer.account.view')
THEN mask_phone(t.counterparty_phone) ELSE t.counterparty_phone END AS counterparty_phone,
CASE WHEN current_setting('app.current_purpose', true) = 'customer.transact'
THEN t.pan_suffix ELSE NULL END AS pan_suffix,
NULL::text AS pan_full,
t.created_at
FROM transactions t
WHERE t.tenant_id = current_setting('app.current_tenant', true);
The API should consume
v_transactionsfor reads to avoid accidental over-exposure.
function tenantPepper(tid: string, master: Buffer): Buffer {
return createHmac("sha256", master).update(`pepper:${tid}`).digest();
}
Avoid enumeration: uniform errors; rate-limit keys are HMAC(tenant, phone) so existence isn’t leaked.
sess:{jti}), issue access JWT (aal:1) + rotating refresh.customer.transact/auth/stepup/complete verifies OTP or device-key signature (preferred), upgrades to aal:2 with short expiry (e.g., 10 min).const origHash = b64url(sha256(`${method}|${path}|${normalizeBody(body)}`));
const challenge = signJWT({ kind:"stepup", sub, tid, orig: origHash, exp: now+300 });
For transactions, include intent signing: the device signs the transfer payload (amount, currency, beneficiaryId, origHash).
Signals: IP reputation, device posture/attestation, SIM age/port-in, geo, velocity, amount vs limits, new beneficiary, time-of-day.
Outputs: risk = low|medium|high, plus explainable components for audit.
Policy: customer.transact always requires AAL2; risk can also trigger step-up for other purposes.
openapi: 3.1.0
info:
title: Customer Auth & PBAC
version: 1.0.0
components:
securitySchemes:
AccessJWT:
type: http
scheme: bearer
bearerFormat: JWT
paths:
/customers/auth/otp/send:
post:
summary: Send phone verification OTP
requestBody: { required: true, content: { application/json: { schema:
type: object, required: [tenantId, phone], properties:
tenantId: { type: string }
phone: { type: string, pattern: "^\\+\\d{7,15}$" } }}}}
responses: { "202": { description: Accepted } }
/customers/auth/otp/verify:
post:
summary: Verify OTP
requestBody: { required: true, content: { application/json: { schema:
type: object, required: [tenantId, phone, otp], properties:
tenantId: { type: string }
phone: { type: string }
otp: { type: string, minLength: 4, maxLength: 8 } }}}}
responses: { "200": { description: OK, content: { application/json:
schema: { type: object, properties: { verificationToken: { type: string } } } } } }
/customers/auth/pin/set:
post:
summary: Set PIN after verified phone
requestBody: { required: true, content: { application/json: { schema:
type: object, required: [tenantId, phone, pin, verificationToken], properties:
tenantId: { type: string }
phone: { type: string }
pin: { type: string, pattern: "^\\d{4,6}$" }
verificationToken: { type: string } }}}}
responses: { "204": { description: No Content } }
/customers/auth/login:
post:
summary: Login with phone + PIN
requestBody: { required: true, content: { application/json: { schema:
type: object, required: [tenantId, phone, pin], properties:
tenantId: { type: string }
phone: { type: string }
pin: { type: string } }}}}
responses:
"200":
description: Tokens
content: { application/json: { schema: { type: object, properties:
accessToken: { type: string }, refreshToken: { type: string },
expiresIn: { type: integer }, sessionId: { type: string }, aal: { type: integer } } } }
"401": { description: Invalid credentials }
/customers/auth/stepup/complete:
post:
summary: Complete step-up for high-risk actions
security: [ { AccessJWT: [] } ]
requestBody: { required: true, content: { application/json: { schema:
type: object, required: [challengeToken], properties:
challengeToken: { type: string },
otp: { type: string, nullable: true },
deviceAssertion: { type: object, nullable: true } }}}}
responses:
"200": { description: Upgraded access, content: { application/json:
schema: { type: object, properties: { accessToken: { type: string }, expiresIn: { type: integer }, aal: { type: integer, enum: [2] } } } } }
/v1/transfers:
post:
summary: Create transfer (purpose = customer.transact)
security: [ { AccessJWT: [] } ]
responses:
"200": { description: Created }
"403": { description: MFA_REQUIRED (bound challenge) }
import { ROUTE_PURPOSE } from "./purpose-map";
import { z } from "zod";
export const AuthzInputSchema = z.object({
tenant: z.object({ id: z.string() }),
subject: z.object({ id: z.string(), type: z.enum(["customer","user","service"]), aal: z.number().int().min(1) }),
resource: z.object({ type: z.string(), id: z.string().optional(), tenant_id: z.string(), attrs: z.record(z.any()).optional() }),
action: z.string(),
purpose: z.string(),
context: z.object({ ip: z.string(), ua: z.string().optional(), risk: z.enum(["low","medium","high"]), time: z.string() })
});
export function attachPurpose(req, _res, next) {
const key = `${req.method} ${req.routePath}`;
const purpose = ROUTE_PURPOSE[key] ?? "operational";
req.purpose = purpose;
req.headers["x-purpose"] = purpose;
req.db.query("select set_config('app.current_purpose',$1,true)", [purpose]).catch(()=>{});
next();
}
export function toAuthzInput(req): z.infer<typeof AuthzInputSchema> {
return {
tenant: { id: req.headers["x-tenant-id"] },
subject: { id: req.user.sub, type: "customer", aal: req.user.aal ?? 1 },
resource:{ type: req.resource?.type ?? "transaction", id: req.resource?.id, tenant_id: req.headers["x-tenant-id"], attrs: req.resource?.attrs },
action: req.action, // e.g., "transfer.create"
purpose: req.purpose, // set by attachPurpose
context: { ip: req.ip, ua: req.headers["user-agent"], risk: req.risk ?? "low", time: new Date().toISOString() }
};
}
export async function authorize(input: any): Promise<{allow:boolean, reason?:string}> {
const payload = { input };
const res = await fetch(process.env.OPA_URL + "/v1/data/payments/authz/allow", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(100)
});
if (!res.ok) throw new Error("authz_unavailable");
const { result } = await res.json();
return { allow: !!result };
}
function origReqHash(req) {
return base64url(sha256(`${req.method}|${req.path}|${normalizeBody(req.body)}`));
}
export function issueChallenge(req, sub: string, tid: string) {
return signJWT({ kind: "stepup", sub, tid, orig: origReqHash(req) }, 300); // 5 minutes
}
export function verifyChallenge(req, token: string) {
const c = verifyJWT(token);
if (c.kind !== "stepup" || c.orig !== origReqHash(req)) throw forbidden("challenge_mismatch");
return c;
}
const POLICY = { memoryMiB: 128, time: 3, parallel: 1, version: 3 };
export async function hashPin(pin: string, pepper: Buffer) {
enforcePinPolicy(pin);
const salt = randomBytes(16);
const cfg = { memoryCost: POLICY.memoryMiB*1024, timeCost: POLICY.time, parallelism: POLICY.parallel, raw: true, type: argon2.argon2id, salt };
const hash = await argon2.hash(Buffer.concat([Buffer.from(pin), pepper]), cfg);
return { hash, salt, version: POLICY.version };
}
export async function verifyPin(pin: string, pepper: Buffer, salt: Buffer, expected: Buffer, storedVersion: number) {
const cfg = { memoryCost: POLICY.memoryMiB*1024, timeCost: POLICY.time, parallelism: POLICY.parallel, raw: true, type: argon2.argon2id, salt };
const computed = await argon2.hash(Buffer.concat([Buffer.from(pin), pepper]), cfg);
const ok = timingSafeEqual(computed, expected);
if (ok && storedVersion < POLICY.version) queueRehashJob(/* ... */);
return ok;
}
-- Rel tuples (Zanzibar-style)
CREATE TABLE rel_tuples (
subject_ns text, subject_id text, relation text,
object_ns text, object_id text,
caveat jsonb,
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);
-- Customers
CREATE TABLE customers (
id uuid PRIMARY KEY,
tenant_id text NOT NULL,
phone_e164 text NOT NULL,
phone_verified_at timestamptz,
status text NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, phone_e164)
);
CREATE TABLE customer_credentials (
customer_id uuid PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
pin_hash bytea NOT NULL,
pin_salt bytea NOT NULL,
kdf_version smallint NOT NULL,
pin_set_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE audit_log (
id bigserial PRIMARY KEY,
ts timestamptz NOT NULL DEFAULT now(),
tenant_id text NOT NULL,
actor jsonb NOT NULL, -- {type:"customer", id:"..."}
action text NOT NULL, -- "auth.login", "transfer.create"
target jsonb NOT NULL, -- {type:"transfer", id:"..."}
decision jsonb NOT NULL, -- {allow:true, policy_version:"...", purpose:"customer.transact", aal:2, trace_id:"..."}
attrs jsonb, -- ip, ua, risk, masking_rule, registry_version
prev_hash bytea,
row_hash bytea
);
Dashboards
customer.transact, median completion time.SLO targets
Alerts: deny storms, AAL violations, masking leaks (unexpected full PII in support purpose), OTP delivery SLA breach, refresh replay spike.
Unit/integration: PIN policy, hashing, throttles, lockouts, OTP/device step-up, refresh rotation & replay kill-switch, OPA allow/deny by purpose & AAL.
Property-based: random attempt sequences across IP/device/phone; assert lockouts and throttle invariants.
Mutation testing: flip Rego rules and throttling comparisons → test suite must fail.
Chaos/game days: kill OTP provider, slow Redis, fail OPA; verify fail-closed and graceful UX.
Formal invariant (excerpt):
transfer.create without purpose=customer.transact and aal ≥ 2.support.investigate.| Domain | Control | Implementation |
|---|---|---|
| PCI DSS 7.x | Access control | OPA PBAC + ReBAC; RLS guardrail; AAL2 for transactions |
| PCI DSS 10.x | Logging | Hash-chained audit to WORM; nightly verification |
| PSD2/SCA | Strong customer auth | AAL2 step-up for customer.transact; OTP or device biometric |
| GDPR/NDPA | Purpose limitation & minimization | PBAC registry; response masking; retention by purpose |
| SOC 2 | Change management | Signed policy/data bundles; blue/green rollouts; evidence exports |
Policy & registry supply chain:
purposes.json against schema; generate coverage.Blue/green policy clusters + canary (1–5%) with shadow-eval drift.
Secrets: peppers/keys in KMS; rotated & zeroized at runtime.
Envoy ext_authz mandatory (no bypass routes).
customer.transact; initially warn only.customer.transact; monitor dashboards.customer.transact.ext_authz filterhttp_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
user:customer_123 member tenant:acme
tenant:acme parent merchant:m_456
user:customer_123 payer account:a_789 (caveat: {"expires_at":"2025-10-01T00:00:00Z"})
customer.transact requires AAL2 and intent signing; responses are purpose-shaped.