Skip to content

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_service verifies Identity's signed identity_assertion against cached keys — it never calls back to Identity mid-decision. Identity already identified the person upstream; the signature is the proof.
  • Local reads only. decision reads 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's fail_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 RosterSource port (SIS for schools, HR for offices); purchase (parks) and visitor pre-reg are internal grants use-cases. The grants module doesn't care which fed it.
  • Config-toggled rules in decision — e.g. "pickup zones require a guardian Relationship." 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.