4. Backend Architecture¶
How the Access backend is built — a thin modular hexagon on top of Identity.
4.1 Shape¶
Access follows the same modular hexagon as the Identity backend (§17) — one module per bounded context, each with its own domain, application, ports, adapters — but thinner, because it's a linked service and reuses Identity for the heavy parts.
| Access reuses from Identity | Access builds |
|---|---|
| palm enroll + identify · the device fleet (mTLS) · sign-in · the broker · consent · audit · webhooks | the access-decision engine · the closed-loop ledger · the operator console + consumer app |
4.2 Repository structure & modules¶
Same layout as Identity (§17), thinner. grants/ is expanded to show the hexagon shape every module shares; the finalized ports and adapters sit where they're used:
link-access-backend/
├── app/
│ ├── main.py # app factory + router wiring
│ ├── core/ # config, db, security (JWKS verify), middleware
│ ├── shared/models/ # SQLAlchemy ORM (accounts, grants, zones, …)
│ ├── modules/
│ │ ├── grants/ # ← the shape every module shares
│ │ │ ├── di.py # wires ports → adapters
│ │ │ ├── domain/ # Grant, Product + rules
│ │ │ ├── application/ # issue-grant use-cases (purchase, visitor)
│ │ │ ├── ports/
│ │ │ │ ├── inbound/ # request/response schemas
│ │ │ │ └── outbound/ # RosterSource (two real impls)
│ │ │ └── adapters/
│ │ │ ├── inbound/ # router.py
│ │ │ └── outbound/ # sis_roster · hr_roster · postgres
│ │ ├── zones/ # Zone, ZoneDevice
│ │ ├── people/ # Membership, Relationship
│ │ ├── ledger/ # Account, Transaction, Posting · spend · refund
│ │ ├── topup/
│ │ │ ├── domain/ # TopUp
│ │ │ ├── ports/outbound/ # PaymentGateway
│ │ │ └── adapters/
│ │ │ ├── inbound/ # Moyasar webhook receiver
│ │ │ └── outbound/ # moyasar · mock (stripe later)
│ │ ├── attendance/ # Attendance
│ │ ├── decision/ # orchestrator: allow / deny (no entities)
│ │ ├── linked_service/ # inbound: broker authorize + assertion verify
│ │ └── identity_client/ # outbound: Identity API client
│ └── tests/ # unit/, integration/
├── alembic/ , alembic.ini # migrations
├── docker-compose.yml , Dockerfile
├── requirements.txt , pytest.ini
├── web/ # React + Vite + Tailwind operator console
└── README.md
A port only exists where a second implementation is real. RosterSource (SIS or HR) and PaymentGateway (Moyasar, mock, Stripe later) earn one. Persistence doesn't — it is Postgres and will stay Postgres, so application calls the Postgres adapter directly rather than through a repository interface with one implementation forever.
Six modules own data; decision, linked_service, and identity_client hold behavior only. Access has no device-fleet / mTLS bootstrap — that's Identity's; the broker reaches Access over HTTPS with a JWT assertion. Every entity from §3 has a home.
4.3 The decision flow¶
When the broker calls Access to authorize a scan, linked_service receives it and decision orchestrates the answer:
sequenceDiagram
participant B as Identity broker
participant L as linked_service
participant D as decision
participant Z as zones
participant G as grants
participant M as ledger
B->>L: authorize(action, context, assertion)
L->>L: verify assertion (cached JWKS)
L->>D: decide(user, device_id, action, context)
alt entry
D->>Z: zone for device_id
D->>G: valid grant for zone now?
else pay
D->>M: debit(user, amount, idempotency_key)
end
D-->>L: allow / deny + message
L-->>B: decision, display_message, reference_id
Note over D: after responding — record attendance / notify (async)
Four rules govern it:
- Trust the assertion.
linked_serviceverifies Identity's signedidentity_assertionagainst cached keys — it never calls back to Identity mid-decision. Identity already identified the person upstream; the signature is the proof. - Local reads only.
decisionreads Access's own modules (zones, grants, ledger) to answer — no network call to Identity or Moyasar while the person is at the gate. - Idempotent pay. A pay debit dedupes on
idempotency_key, so a device retry never double-charges. - Fail-closed. Any error or uncertainty returns
deny— a gate that can't confirm stays shut (matching Identity'sfail_mode).
Side-effects — recording attendance, notifying a guardian — happen after the response, off the critical path.
4.4 Ports¶
Access has two outbound ports, following Identity's pluggable-vendor pattern:
PaymentGateway — pulls real money in and pushes refunds out. First adapter: Moyasar (KSA — mada, Apple Pay, STC Pay). Contract: charge(amount, currency, source, idempotency_key), refund(gateway_payment_id, amount), and a webhook the topup module trusts to confirm paid / failed. A mock adapter covers tests; a Stripe adapter is added when Access goes global.
identity_client — Access's outbound calls to Identity, used off the hot path (never during a decision): get_user(user_id) for names and mobiles in the console and app. Inbound Identity webhooks (user.*, enrollment.complete, consent.granted) land in a separate receiver, not this port.
4.5 Segment variation¶
Schools, offices, and parks run the same modules. Their differences live in three places, never in per-segment code:
- Config — which effects a zone fires, funding rules and spend caps, the consent actor. Tenant/zone settings on the shared modules.
- Adapters — how grants are created differs by segment, so external sources plug in behind a
RosterSourceport (SIS for schools, HR for offices); purchase (parks) and visitor pre-reg are internalgrantsuse-cases. Thegrantsmodule doesn't care which fed it. - Config-toggled rules in
decision— e.g. "pickup zones require a guardianRelationship." The rule is shared; the toggle and the relationship data are config.
A tenant's segment profile bundles which settings and adapters apply. There are no schools / offices / parks modules — that would re-fragment the engine.