A centralised entitlement service for a financial organisation. Models access using a Neo4j graph of Parties, Party Roles (BIAN-aligned), Permissions, and Resources, with role inheritance and deny-precedence semantics. Built on .NET 8 Minimal APIs.
"Is this subject allowed to perform this permission on this resource?"
This service has no built-in authentication or authorisation. It trusts the subject field in every request and answers entitlement questions about that subject. Deploy it only behind an authenticated gateway (API gateway, BFF, service mesh, etc.) that establishes the caller's identity and translates it into the subject value passed in.
Direct exposure to an untrusted network would let any caller assert any subject id — i.e., look up anyone's entitlements.
The endpoint returns 200 OK for both allow and deny outcomes. The HTTP status describes the transport, not the authorisation decision:
200 OK— the engine evaluated the request. Readallowed: true/allowed: falsefrom the response body.4xx— the request was malformed (bad JSON, missing field, validation failure).5xx— the engine itself failed (Neo4j unreachable, query error).
Treat the response body as authoritative for authorisation outcomes.
# Set a Neo4j password — there is no baked-in default; the stack will refuse to start otherwise.
# Copy .env.example to .env and edit, or export inline for an ephemeral demo:
export NEO4J_PASSWORD=$(openssl rand -hex 16)
docker compose up --build -d
curl -X POST http://localhost:8080/v1/admin/seed
curl -X POST http://localhost:8080/v1/entitlements/check \
-H "Content-Type: application/json" \
-d '{"subject":"alice","permission":"VIEW_ACCOUNT","resource":"ACC-001"}'Expected response:
{
"allowed": true,
"permission": "VIEW_ACCOUNT",
"reason": "Granted via role 'Customer' (instance grant on 'ACC-001')",
"grantedBy": {
"rolePath": ["Customer"],
"grantingRole": "Customer",
"permission": "VIEW_ACCOUNT",
"grantType": "instance",
"appliesTo": "ACC-001"
},
"deniedBy": null
}An explicit-deny outcome carries the deny path on deniedBy and a null grantedBy — the two fields are symmetric, so a single /check round-trip is sufficient for audit:
{
"allowed": false,
"permission": "TRANSFER_FUNDS",
"reason": "Explicit deny via role 'FrozenAccountHolder'",
"grantedBy": null,
"deniedBy": {
"rolePath": ["FrozenAccountHolder"],
"grantingRole": "FrozenAccountHolder",
"permission": "TRANSFER_FUNDS",
"grantType": "instance",
"appliesTo": "ACC-003"
}
}A "no matching grant" deny returns both fields as null.
Browse:
- Swagger UI — http://localhost:8080/swagger
- Health probe — http://localhost:8080/health
- Neo4j Browser — http://localhost:7474 (user
neo4j, password is whatever you set inNEO4J_PASSWORD)
Entitlement evaluation is fundamentally a reachability problem: "is there a path from this subject, through the roles they hold (including inherited ones), to a permission that applies to this resource?" That's a natural fit for graph traversal — and the question becomes harder to express cleanly in relational SQL the moment you add role inheritance or hybrid (type-level + instance-level) grants.
This service uses Cypher's variable-length path patterns ([:INHERITS_FROM*0..10]) to walk role hierarchies in one query, and Neo4j 5's EXISTS { … } subquery to apply deny-precedence in a single round-trip:
MATCH allowPath = (party:Party {partyId: $subject})
-[:HAS_ROLE]->(:PartyRole)-[:INHERITS_FROM*0..10]->(role:PartyRole)
-[:GRANTS]->(perm:Permission {action: $permission})
WHERE ( (perm)-[:ON_INSTANCE]->(:Resource {resourceId: $resource})
OR (perm)-[:ON_TYPE]->(:ResourceType)<-[:OF_TYPE]-(:Resource {resourceId: $resource}))
AND NOT EXISTS { /* matching DENIES path */ }
RETURN allowPath LIMIT 1The same query handles direct grants, inherited grants, instance-scoped grants, type-scoped grants, and explicit denies — all in one transactional read. The depth bound (*0..10) is a planner safeguard against an accidental cycle in the role graph; BIAN-style hierarchies are typically ≤ 4 levels deep, so 10 leaves comfortable headroom.
graph LR
Party([Party])
PartyRole([PartyRole])
Permission([Permission])
Resource([Resource])
ResourceType([ResourceType])
Party -- HAS_ROLE --> PartyRole
PartyRole -- "INHERITS_FROM (role hierarchy)" --> PartyRole
PartyRole -- "GRANTS (allow)" --> Permission
PartyRole -- "DENIES (beats allow)" --> Permission
Permission -- ON_INSTANCE --> Resource
Permission -- ON_TYPE --> ResourceType
Resource -- OF_TYPE --> ResourceType
Notes:
DENIESbeatsGRANTSregardless of role-inheritance depth — seeCypherQueries.Checkfor the precedence rule.- A
Permissionhas exactly one ofON_INSTANCEorON_TYPE, never both. The invariant is enforced at host startup bySchemaInitializer.AssertSingleScopePerPermissionAsync. INHERITS_FROMis traversed up to a bounded depth on every check — seeCypherQueries.MaxDepthfor the cap (currently 10; BIAN-style role hierarchies are shallow in practice).
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/entitlements/check |
Single decision: allow/deny + reason + granting path |
POST |
/v1/entitlements/check-bulk |
Many decisions for one subject in a single call (max 25) |
GET |
/v1/entitlements/explain |
All allow + deny paths that bear on a decision |
GET |
/v1/parties/{id}/permissions |
Every effective permission for a party |
POST |
/v1/admin/seed |
Wipe and reload the demo dataset (Development only) |
GET |
/health |
JSON liveness probe (pings Neo4j; unversioned operational contract) |
GET |
/swagger |
Interactive API docs (Development environment only) |
All request validation uses FluentValidation. Failures return RFC 7807 Problem Details (application/problem+json):
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Subject": ["'Subject' must not be empty."]
}
}The exception handler also converts malformed JSON and missing required parameters into 400 Problem Details (not 500).
Created by POST /admin/seed.
| Party | Roles |
|---|---|
| alice | Customer |
| bob | Teller |
| carol | SeniorTeller |
| dave | BranchManager |
| eve | Customer + FrozenAccountHolder |
BranchManager ──▶ SeniorTeller ──▶ Teller
Customer (standalone)
FrozenAccountHolder (standalone, deny-only)
| Role | Permission | Scope |
|---|---|---|
| Customer | VIEW_ACCOUNT | instance: ACC-001 |
| Teller | VIEW_ACCOUNT | type: Account |
| SeniorTeller | TRANSFER_FUNDS | type: Account |
| BranchManager | APPROVE_LOAN | type: Loan |
| FrozenAccountHolder | DENIES TRANSFER_FUNDS | instance: ACC-003 |
| Resource | Type |
|---|---|
| ACC-001 | Account |
| ACC-002 | Account |
| ACC-003 | Account (frozen — eve has a deny here) |
| LOAN-001 | Loan |
# 1. Allow via instance grant (alice owns ACC-001 through Customer role)
curl -X POST http://localhost:8080/v1/entitlements/check \
-H "Content-Type: application/json" \
-d '{"subject":"alice","permission":"VIEW_ACCOUNT","resource":"ACC-001"}'
# → allowed: true, rolePath: ["Customer"]
# 2. Allow via inherited role (carol is SeniorTeller, inherits Teller's VIEW)
curl -X POST http://localhost:8080/v1/entitlements/check \
-H "Content-Type: application/json" \
-d '{"subject":"carol","permission":"VIEW_ACCOUNT","resource":"ACC-001"}'
# → allowed: true, rolePath: ["SeniorTeller", "Teller"]
# 3. Deny by explicit DENIES (eve frozen on ACC-003)
curl -X POST http://localhost:8080/v1/entitlements/check \
-H "Content-Type: application/json" \
-d '{"subject":"eve","permission":"TRANSFER_FUNDS","resource":"ACC-003"}'
# → allowed: false, reason: "Explicit deny via role 'FrozenAccountHolder'"
# 4. Explain — see ALL granting paths for a deep hierarchy
curl "http://localhost:8080/v1/entitlements/explain?subject=dave&permission=VIEW_ACCOUNT&resource=ACC-001"
# → decision: allow, allowPaths: [{rolePath: ["BranchManager","SeniorTeller","Teller"], ...}]
# 5. Effective permissions for a party
curl http://localhost:8080/v1/parties/dave/permissions
# → APPROVE_LOAN (BranchManager) + TRANSFER_FUNDS (SeniorTeller) + VIEW_ACCOUNT (Teller)
# 6. Bulk check
curl -X POST http://localhost:8080/v1/entitlements/check-bulk \
-H "Content-Type: application/json" \
-d '{"subject":"alice","checks":[
{"permission":"VIEW_ACCOUNT","resource":"ACC-001"},
{"permission":"TRANSFER_FUNDS","resource":"ACC-001"},
{"permission":"APPROVE_LOAN","resource":"LOAN-001"}
]}'The test suite uses Testcontainers to spin up a real Neo4j 5 container per test class — proving the Cypher works against real Neo4j semantics, not a mock. Docker must be running.
dotnet test| Test class | Concern | Tests |
|---|---|---|
EntitlementRepositoryTests |
Spec scenarios T1–T13 (allow/deny happy paths) | 13 |
EntitlementRepositoryEdgeCaseTests |
Case sensitivity, scope limits, hierarchy directionality | 11 |
EntitlementEndpointsIntegrationTests |
HTTP-level happy paths via WebApplicationFactory |
9 |
EntitlementEndpointsErrorTests |
Validation failures, malformed input, method/route errors | 11 |
SeedRepositoryTests |
Seeded graph shape, idempotency, no orphan nodes | 9 |
| Total | 53 |
Total wall-clock: ~25 seconds (first run includes Neo4j image pull; subsequent runs ~10s).
- Authorization semantics: instance grants, type grants, role inheritance traversal, deny precedence, deny scoping.
- API contract: each endpoint accepts the documented request shape and returns the documented response shape with the right status codes.
- Validation: empty fields, oversized fields, malformed JSON, missing query params all return RFC 7807 Problem Details.
- Data invariants: seed produces the expected node counts; no orphan Permission nodes; re-seeding is idempotent.
| Decision | Choice | Why |
|---|---|---|
| Database | Neo4j 5 | Graph traversal = entitlement check. Cypher's *0.. path operator and EXISTS { } subquery let us express deny-precedence in one query. |
| API style | Minimal APIs | Two endpoints don't justify controller scaffolding. Program.cs stays readable as a composition root. |
| Authz library | None | Wrote from first principles per the brief. The Cypher query is the policy engine. |
| Validation | FluentValidation + endpoint filter | Returns RFC 7807 ValidationProblem on failure. Scanned from the assembly so adding a validator is a single new class. |
| Logging | Built-in JSON console formatter | Structured audit logs without taking on Serilog as a dependency. Every authorisation operation emits one log line under the EntitlementCheck.Api.Services.EntitlementAudit category: /check and /check-bulk log subject/permission/resource/decision, /explain adds the allow- and deny-path counts, /parties/{id}/permissions logs the permission count. Each line carries TraceId and CorrelationId. |
| Test infra | xUnit + Testcontainers.Neo4j | Tests run against real Neo4j, not mocks. One container per test class (parallel-safe). |
| Bulk check | Service-layer loop (not UNWIND) |
Reuses the well-tested single-check Cypher. For a production system with very high call volume, a single UNWIND round-trip would outperform the loop. |
| Grant scoping | Role-scoped (not party-scoped) | Customer's instance grant applies to every Customer. A real banking system might want party-scoped grants (e.g., a :OWNS edge), which is a model extension worth flagging. |
.
├── docker-compose.yml ← Neo4j + API
├── Dockerfile ← multi-stage build for the API
├── global.json ← pins .NET 8 SDK
├── NuGet.config ← nuget.org only (portable)
├── .editorconfig, .gitignore
├── EntitlementCheck.sln
├── src/EntitlementCheck.Api/
│ ├── Program.cs ← composition root: DI, logging, Swagger, error handling
│ ├── Endpoints/ ← Minimal API route groups
│ ├── Domain/ ← request/response records
│ ├── Graph/ ← CypherQueries.cs + repositories
│ ├── Services/ ← orchestration + audit logging
│ ├── Validation/ ← FluentValidation + endpoint filter
│ └── Health/ ← Neo4jHealthCheck
└── tests/EntitlementCheck.Tests/
├── Fixtures/
│ ├── Neo4jFixture.cs ← shared Testcontainers Neo4j (IAsyncLifetime)
│ └── EntitlementApiFactory.cs ← WebApplicationFactory<Program> override
└── *Tests.cs ← integration + repository + endpoint scenarios
| Setting | Default | Notes |
|---|---|---|
Neo4j:Uri |
bolt://localhost:7687 |
Neo4j Bolt URI |
Neo4j:User |
neo4j |
Username |
Neo4j:Password |
(empty — must be supplied) | Password — see below |
Neo4j:Database |
neo4j |
Target database name. Sessions are bound to this explicitly, so multi-database deployments only need to flip this value. |
Admin:EnableSeedEndpoint |
false |
Set true to map POST /v1/admin/seed outside Development |
Neo4j:Uri, User, and Password are validated at startup. A missing or empty value fails the host with OptionsValidationException rather than silently falling back to a demo password.
Where to put the password
-
Docker Compose — set
NEO4J_PASSWORDin your shell or in a.envfile at the repo root (gitignored). Both theneo4jservice auth (NEO4J_AUTH) and the API'sNeo4j__Passwordresolve from the same variable. The stack fails fast with a clear message if it is unset:# Either via .env file: cp .env.example .env && edit .env # Or inline for an ephemeral run: NEO4J_PASSWORD=$(openssl rand -hex 16) docker compose up
-
dotnet runon the host — store it in user-secrets so it's not committed:cd src/EntitlementCheck.Api dotnet user-secrets init dotnet user-secrets set "Neo4j:Password" "<your-password>"
-
CI / other environments — supply as an environment variable (
Neo4j__Password, note the double underscore) or via your platform's secret manager.
If you'd rather run the API on your host and Neo4j in Docker:
export NEO4J_PASSWORD=$(openssl rand -hex 16)
docker compose up neo4j -d # Neo4j only
cd src/EntitlementCheck.Api
dotnet user-secrets init # one-time
dotnet user-secrets set "Neo4j:Password" "$NEO4J_PASSWORD"
dotnet run # API on http://localhost:5000 or so
curl -X POST http://localhost:5000/v1/admin/seedIf you skip the user-secrets step, startup will fail with an OptionsValidationException complaining about Neo4j:Password — that's the fail-fast behaviour by design.
Tests fail with "Cannot connect to Docker"
Testcontainers needs Docker Desktop (Windows/macOS) or a running Docker daemon (Linux). Start Docker and re-run dotnet test.
docker compose up succeeds but API returns 503 from /health
The API container starts only after Neo4j's healthcheck passes (depends_on: condition: service_healthy). If Neo4j is slow to start (cold image, low memory), the API will retry connections silently. Check docker compose logs neo4j for errors.
Seed seems to keep old data after docker compose down && docker compose up
The Neo4j data volume is persisted by Compose. Use docker compose down -v to also remove the volume for a clean slate.
docker compose up fails with "NEO4J_PASSWORD must be set"
The compose file no longer ships a baked-in password. Set the variable in your shell or in a .env file at the repo root before running docker compose up. See .env.example for the template.
Want to inspect the graph visually?
Browse to http://localhost:7474, log in with neo4j / your NEO4J_PASSWORD, and try:
MATCH (p:Party)-[:HAS_ROLE]->(r:PartyRole) RETURN p, r LIMIT 25dotnet test looks like it's hung
It isn't. Each test class spins up a fresh Neo4j container via Testcontainers (~5–10 seconds each). The first run on a clean machine also pulls the neo4j:5.20-community image (~30 seconds). Wall-clock for the whole suite is ~25 seconds on a warm Docker cache. Watch docker ps in another shell to see the containers come up.
Which port am I supposed to hit? Two scenarios use different ports:
- Docker Compose binds
localhost:8080to the API container (perdocker-compose.yml). The container talks plain HTTP — production deployments are expected to put a TLS-terminating gateway in front (see Trust boundary below). dotnet runon the host bindslocalhost:5210(HTTP) andlocalhost:7274(HTTPS) perlaunchSettings.json. HTTPS uses the ASP.NET Core dev cert — rundotnet dev-certs https --trustonce to remove browser warnings. The host hasUseHttpsRedirectionenabled, so HTTP requests are redirected to HTTPS.
Both are correct — pick the one that matches how you started the app.
Trust boundary
The API is designed to run behind an authenticated, TLS-terminating gateway. The application enables UseHttpsRedirection unconditionally and UseHsts outside Development, so direct callers must speak HTTPS. The Docker image binds plain HTTP on port 8080 — that is appropriate when (and only when) a gateway sits in front. Do not expose port 8080 directly to untrusted networks.
Optional gateway handshake. As defence in depth, the API can refuse requests that do not carry a gateway-supplied header. Configure with:
"Trust": {
"RequireGatewayHeader": true,
"GatewayHeaderName": "X-Gateway-Verified"
}When enabled, any request to /v1/* without X-Gateway-Verified returns 401 Unauthorized with an RFC 7807 envelope. /health and /swagger/* are exempt — those endpoints are addressed by the operator, not the API consumer. The handshake is not authentication (the gateway is still responsible for caller identity); it is a one-bit signal that says "yes, a gateway is in front of me." Recommended in production. Disabled by default so the demo flow and the test suite are unaffected. If the flag is left off in a non-Development environment, a Warning-level boot log line flags the misconfiguration.
Rate limits
POST /v1/entitlements/check-bulk is capped at 10 requests per second per process (queue 0); excess requests receive 429 Too Many Requests. The cap exists because each bulk request can fan out to 25 Cypher round-trips. The other endpoints are not rate-limited at the application layer — the upstream gateway is expected to enforce per-caller limits.
- Authentication of API callers. This is the entitlement engine, not the auth gateway. Production would put an API gateway in front handling JWT/OAuth.
- Admin CRUD for roles/permissions. The brief asks for the check engine, not a management UI. Seed handles demo data; production would have a separate admin surface.
- Multi-tenancy. Single-tenant model. Production would partition the graph or use Neo4j's multi-database feature.
- Caching. Entitlement checks are read-heavy and stable, so caching is a real win in production — but invalidation gets tricky. Out of scope for the demo.
- Party-scoped instance grants. Current grants are role-scoped: every Customer can view ACC-001. A real banking system would likely add a
:OWNSedge from Party to Resource that instance grants reference. Documented as a known extension point.