Skip to content

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-permission endpoint 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 — senior has no implicit inheritance from operations. All elevated grants for senior are expressed as explicit role_permissions rows 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_revoked events are SIEM-only — Lambdas do not subscribe.
  • AD-5 AML-006 SAR deny list is hard-coded in src/lib/restricted.ts as a v1 seed: aml.alerts.*, aml.cases.*, and the named sar_* columns on app.cases. Reconcile with bank-aml's actual schema once MOD-018/019 land (TODO referenced from restricted.ts).
  • AD-6 Multi-tenant capabilities (CAP-031..CAP-034) are vacuously satisfied by the single-tenant totara-bank deployment — 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 to access.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_log insert failures, not on CW-Logs audit_log_cw_write_failed stderr 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.