MOD-052 — Role-scoped data access (technical design)¶
Module: MOD-052
System: SD08 (bank-app)
Repo: bank-app
FR scope: FR-325, FR-326, FR-327, FR-328
NFR scope: NFR-009, NFR-023, NFR-024
Policies satisfied: DT-001 (GATE), PRI-001 (AUTO), AML-006 (AUTO)
Dependencies: MOD-068 (Built — owns access.user_identities), MOD-044 (Built), MOD-103 (Built), MOD-104 (Built)
Date: 2026-05-07
Objective¶
Enforce back-office attribute-level data access at the API layer. Once a
staff JWT (issued by the bank-staff-* Cognito pool, validated by MOD-044)
reaches a BFF Lambda, MOD-052 decides which attributes of the entity
being read the caller is allowed to see based on their role.
Three Postgres tables (access.user_roles, access.role_permissions,
access.access_log), four Lambda HTTP endpoints (3 admin + 1 HTTP shim),
and a workspace-package enforcement library that every back-office BFF
imports and calls before returning data.
Architectural decisions (from scope review, ratified 2026-05-07)¶
- AD-1 Library is canonical; HTTP
check-permissionendpoint is a thin shim for non-workspace callers. The library gives BFFs zero-network-hop enforcement and a compile-time contract. - AD-2 Roles are parallel and explicit —
seniorhas no implicit inheritance fromoperations. All elevated grants for senior are expressed as explicitrole_permissionsrows so the matrix is auditable. - AD-3 Column-level permission grain — matches FR-325 "data attributes" literally. No data-classification mapping layer.
- AD-4 60-second TTL cache satisfies FR-328; cold-start Lambdas always
load fresh, so the worst-case staleness applies only to warm instances.
bank.app.role_assigned/role_revokedevents are SIEM-only — Lambdas do not subscribe. - AD-5 AML-006 SAR deny list is hard-coded in
src/lib/restricted.tsas a v1 seed:aml.alerts.*,aml.cases.*, and the namedsar_*columns onapp.cases. Reconcile with bank-aml's actual schema once MOD-018/019 land (TODO referenced fromrestricted.ts). - AD-6 Multi-tenant capabilities (CAP-031..CAP-034) are vacuously
satisfied by the single-tenant
totara-bankdeployment — no multi-tenant code is shipped. - AD-7
access.access_log(Postgres, ADR-048 Cat 1) is the durable 7-year compliance record. CloudWatch Logs is a secondary SIEM feed. If the CW Logs write fails (e.g. BankAppRole IAM grant pending), the failure is logged to stderr but is not a compliance incident. Ops: do not page on CW Logs write failures from MOD-052. - AD-8 No real Cognito staff-pool sign-up in dev. Integration tests'
seedTestStaff()writes directly toaccess.user_identities.
Stacks¶
MOD-052-role-scoped-data-access/
├── infra/
│ ├── functions.ts 4 Lambdas, x86_64, $cli.paths.root for code asset
│ ├── api.ts HTTP API v2 — POST/DELETE/GET /admin/roles + POST /check-permission
│ ├── audit-log.ts /aws/bank-app/access-log-{env}, 90d hot retention, KMS-CMK encrypted
│ ├── ssm-outputs.ts eight SSM parameters (table below)
│ └── index.ts
├── src/
│ ├── lib/
│ │ ├── enforce.ts ★ canonical enforcement function
│ │ ├── role-cache.ts 60s TTL per FR-328
│ │ ├── restricted.ts AML-006 SAR deny list (AD-5)
│ │ ├── audit.ts two-tier writer: Postgres-first (AD-7), CW-Logs best-effort
│ │ ├── errors.ts AccessError + envelope mapping
│ │ ├── logger.ts ADR-031 structured logs
│ │ ├── trace.ts
│ │ └── db.ts pg.Pool factory
│ ├── services/
│ │ ├── role-service.ts CRUD on user_roles + role_permissions
│ │ └── event-publisher.ts EventBridge bank-app bus
│ ├── handlers/
│ │ ├── admin-assign-role.ts
│ │ ├── admin-revoke-role.ts
│ │ ├── admin-list-roles.ts
│ │ └── check-permission.ts
│ └── index.ts workspace-library exports
├── db/migrations/V001..V003.sql + rollbacks
└── tests/ unit ≥80%, contract, policy (static), integration FR/policy/infra
Data model¶
access.user_roles (V001)¶
Back-office staff role assignments. One active role per staff user at a
time — partial UNIQUE index on (user_id) WHERE revoked_at IS NULL.
Mutable; revocation stamps revoked_at rather than deleting the row.
access.role_permissions (V002)¶
Column-level permission matrix: one row per (role, entity, attribute).
granted = true allows; granted = false is an explicit deny that
overrides any wildcard. Per AD-2 there is no inheritance: senior's
elevated access is a literal set of rows in this table. V002 ships
the v1 seed for the four FR-325 roles.
access.access_log (V003)¶
Append-only audit log. ADR-048 Cat 1 immutability via
trg_access_log_immutable (BEFORE UPDATE OR DELETE OR TRUNCATE). 7-year
retention via FR-327.
SSM outputs published¶
| Path | Value | Consumer |
|---|---|---|
/bank/{env}/mod052/admin-api/url |
API Gateway URL | back-office UI / scripts |
/bank/{env}/mod052/admin-api/id |
API Gateway ID | tooling |
/bank/{env}/mod052/admin-assign-role/fn-arn |
Lambda ARN | observability |
/bank/{env}/mod052/admin-revoke-role/fn-arn |
Lambda ARN | observability |
/bank/{env}/mod052/admin-list-roles/fn-arn |
Lambda ARN | observability |
/bank/{env}/mod052/check-permission/fn-arn |
Lambda ARN | non-workspace callers |
/bank/{env}/mod052/access-log/group-arn |
CW Logs group ARN | MOD-076 subscription |
/bank/{env}/mod052/access-log/group-name |
CW Logs group name | smoke test |
Events published (bank-app bus)¶
| Event | When | Field set | Consumer |
|---|---|---|---|
bank.app.access_denied |
every enforce() with ≥1 denied attribute |
trace_id, staff_id, role, entity, denied_attributes, deny_reason, jurisdiction | SIEM (NFR-023) |
bank.app.role_assigned |
admin assigns a role | trace_id, staff_id, role, granted_by_staff_id | SIEM/observability |
bank.app.role_revoked |
admin revokes a role | trace_id, staff_id, role, revoked_by_staff_id | SIEM/observability |
Test evidence¶
| FR / Policy | Pass |
|---|---|
| FR-325 role-scoped attributes (live) | ✓ |
| FR-326 403 + reason code (live) | ✓ |
| FR-327 access_log writes (live) | ✓ |
| FR-328 60s propagation (cache contract + cold-start fresh-load) | ✓ |
| DT-001 GATE — static (no bypass tokens) + live (negative) | ✓ |
| PRI-001 AUTO — static (every handler gates) + live | ✓ |
| AML-006 AUTO — static (seed correct) + live (lockout + immutability) | ✓ |
| NFR-024 access_log immutability (UPDATE/DELETE rejected) | ✓ |
| Coverage gate ≥80% line/function (vitest config) | ✓ |
Cross-module IAM stop-gap¶
AuditWriter's CloudWatch Logs branch fails closed via stderr if BankAppRole
lacks logs:CreateLogStream / logs:PutLogEvents on /aws/bank-app/*.
The Postgres access.access_log write is the durable compliance record
and is unaffected. Tracked in
docs/handoffs/MOD-104-auth-events-log-iam-grant.handoff.md.
Operational notes¶
- Audit hierarchy (AD-7): page on Postgres
access.access_loginsert failures, not on CW-Logsaudit_log_cw_write_failedstderr lines. - 60s role-cache (AD-4): emergency revocations requiring instant effect can be guaranteed by a Lambda restart (clears the in-memory Map).
- Single-tenant deployment (AD-6): the YAML capability text was rewritten from aspirational multi-tenant phrasing to single-tenant reality during the scope review.