NetFoundry Authorization Service
A complete guide to understanding and configuring the permission model — from core concepts to practical examples of granting access to real users.
Overview — What Does This Service Do?
The Authorization Service is the single source of truth for who can do what in the NetFoundry platform. It answers two fundamental questions:
- Does Alice have permission to read network
xyz-789? - Does the Network Service have permission to create an endpoint under network
xyz-789?
It does this through three responsibilities:
🔑 Token Issuance
Exchanges an Auth0 login token for a short-lived NF JWT that embeds a compact summary of the identity's grants.
🔓 Authorization Check
Answers GET /authorizations?identityId=... — returns the full, expanded set of permissions for any identity.
🗂️ Permission Data Model
Stores and manages Domains, Resource Types, Actions, Standard Roles, Custom Roles, and all grant assignments.
Domains
A Domain is the primary segregation boundary in the permission model. It is a namespace that says: "these resource types and their actions belong together and are owned by one microservice area."
Domain Is a Hard Wall
Every authorization check starts by matching the domain code. A grant in io.netfoundry.network is completely invisible when checking an io.netfoundry.identity action. You cannot cross domain boundaries with a single grant.
Authorization.isAuthorized(
identityId,
authorizationSet,
"io.netfoundry.network", // ← domain code matched first
"network", // ← then resource type code
"read", // ← then action code
targetPath
);
This means "Org Admin" (a grant in the identity domain) gives zero network permissions, and "Network Group Admin" (a grant in the network domain) gives zero identity permissions. They are separate grants in separate domains. See Org Admin Explained for a full walkthrough.
All Domains in the System
Domains are seeded by Flyway migration and never change at runtime. There are 7 in the system:
| Domain Code | Microservice Area | What It Protects |
|---|---|---|
io.netfoundry.identity |
Identity Service | Organizations, user-identities, API accounts, service identities, invitations |
io.netfoundry.network |
Core / Network Service | Network-groups, networks, endpoints, appwans, services, edge routers, clusters |
io.netfoundry.auth |
Auth Service (self) | The permission model itself — standard roles, custom roles, grants, super-users, actions |
io.netfoundry.billing |
Billing Service | Billing accounts |
io.netfoundry.management |
Management Service | Management service resources, executions, controllers |
io.netfoundry.entitlement |
Entitlement Service | Entitlement and licensing resources |
io.netfoundry.app |
Applications / Frontdoor | Frontdoor and application resources |
How the Layers Build Up
Every permission in the system is ultimately described by these four layers, each narrowing the scope further:
| Layer | Table | Answers | Example |
|---|---|---|---|
| Domain | Domain |
Which microservice area? | io.netfoundry.network |
| Resource Type | ResourceType |
What kind of thing? | network-group, network, endpoint |
| Resource Action | ResourceAction |
What verb? | create, read, delete, ziti-token-exchange |
| Action Inclusion | ActionInclusion |
When does a strategy (Admin / Read-Only) include this action? | Admin includes create only when grant is below the resource |
All four are seeded once by Flyway migration and never change at runtime. They define the vocabulary of what can be protected. StandardRoles and grants then use this vocabulary to express who can do what.
Domain and SuperUser Scoping
The SuperUser table has a nullable domainId column. This lets you create a super-user scoped to a single domain:
SuperUser { identityId = alice-uuid, domainId = null }
→ Full access to every resource in every domain (platform root)
SuperUser { identityId = bob-uuid, domainId = <id of io.netfoundry.network> }
→ Full access to all network resources only; zero access to identity or billing
Resource Types
A Resource Type defines a category of things that can be acted upon within a domain. Resource types form a tree via the parentId column — a root type has parentId = null, and child types point to their parent.
The tree defines containment: a network lives inside a network-group, an endpoint lives inside a network. The depth of a resource type in the tree drives the above / at / below logic used by every authorization check.
Identity Domain Tree
├─ user-identity (depth 1)
├─ api-account-identity (depth 1)
├─ service-identity (depth 1)
└─ invitation (depth 1)
An "Org Admin" grant is at depth 0. All identities are at depth 1 — they are "below" the grant, so Admin's create / delete (includeWhenBelow=true) rules apply to them.
Network Domain Tree
├─ azure-subscription (depth 1)
├─ netflow-collector (depth 1)
└─ network (depth 1)
├─ endpoint (depth 2)
├─ service (depth 2)
├─ appwan (depth 2)
├─ endpoint-group (depth 2)
├─ gateway-cluster (depth 2)
├─ certificate-authority (depth 2)
└─ client-sync (depth 2)
geo-region (depth 0 — independent root, no children)
data-center (depth 0 — independent root, no children)
How Depth Controls What a Grant Covers
When you hold a StandardRole grant, the path in that role is at a specific depth. The depth of the action's resource type relative to the grant's resource type determines which ActionInclusion flag applies:
| Grant depth | Action's resource type | Relationship | Flag used |
|---|---|---|---|
| 0 (network-group) | network-group (same) | At | includeWhenAt |
| 0 (network-group) | network (depth 1) | Below | includeWhenBelow |
| 0 (network-group) | endpoint (depth 2) | Below | includeWhenBelow |
| 1 (network) | network-group (depth 0) | Above | includeWhenAbove |
Resource Actions
A Resource Action is something that can be performed on a Resource Type. Every resource type gets four standard CRUD actions automatically, plus any custom actions added later.
Standard Actions (auto-created for every resource type)
| Code | Name | Meaning |
|---|---|---|
create | Create | Create a new instance of this resource |
read | Read | View/list instances of this resource |
update | Update | Modify an existing instance |
delete | Delete | Remove an existing instance |
Custom Actions (examples)
| Resource Type | Custom Action Code | Meaning |
|---|---|---|
edge-router | update-heal | Trigger a heal/resize operation |
edge-router | update-version | Upgrade firmware version |
edge-router | update-suspend | Suspend the edge router |
network | update-move | Move network to a different host |
network | update-backup | Backup network data |
user-identity | update-add-admin | Promote a user to admin |
user-identity | update-reset-mfa | Reset MFA for a user |
Resource Paths — The Heart of Authorization
A ResourceIdPath is a chain of (resourceType, UUID) pairs from the root of a resource tree down to a specific resource. It looks conceptually like:
network-group/<ng-uuid> → network/<net-uuid> → endpoint/<ep-uuid>
How path matching works
When checking authorization, the system compares the identity's grant path against the target resource path:
| Grant Path | Target Path | Authorized? | Why |
|---|---|---|---|
network-group/abc |
network-group/abc → network/xyz |
✅ Yes | Grant path is a prefix of target — covers everything under that network group |
network-group/abc → network/xyz |
network-group/abc → network/xyz → endpoint/123 |
✅ Yes | Grant path prefixes the target — granted access to that network's resources |
network-group/abc → network/xyz |
network-group/abc → network/other |
❌ No | Paths diverge at the network level — only xyz is covered, not other |
network-group/abc → network/xyz |
network-group/abc |
❌ No | Grant path is longer than target — grant is below the target, not above it |
| (no ancestry rows) | any path | ✅ Yes | No ancestry rows means the grant applies to ALL instances of that resource type (global scope) |
network-group/abc → network/xyz does NOT authorize access to
network-group/abc itself. It only authorizes actions at and below network/xyz.
Organizations
An Organization is the top-level container in the identity domain. It is a ResourceType with code organization in domain io.netfoundry.identity.
Everything in the NetFoundry platform is scoped to an organization. Networks, users, API accounts — they all "belong to" an organization at the top of their resource path.
What an Organization contains
org-abc-123)├─ UserIdentity — human users (Alice, Bob)
├─ ApiAccountIdentity — programmatic API clients
├─ ServiceIdentity — internal NF microservices
└─ Invitation — pending user invitations
In the network domain, a NetworkGroup is also scoped to an organization. So the full path to a network looks like:
organization/org-abc-123 → network-group/ng-xyz → network/net-789
io.netfoundry.network). An Organization is a runtime resource instance — a real customer account identified by a UUID.
Users and Identities
In NetFoundry, Identity is the abstract concept — a UUID-based principal that can hold grants. There are several concrete identity types:
| Type | Code | Who uses it |
|---|---|---|
| User Identity | user-identity |
Human users who log in via Auth0 (username/password, Google, SSO) |
| API Account Identity | api-account-identity |
Scripts, automation tools, and third-party integrations |
| Service Identity | service-identity |
Internal NetFoundry microservices (Network Service, Identity Service, etc.) |
All three types share the same authorization model. A User Identity and an API Account Identity can hold the same kinds of grants — Standard Roles, Custom Roles, direct action grants, etc.
How a new user is set up under an organization
Invite or create the user
An organization admin calls POST /identity/v1/invitations or POST /identity/v1/user-identities. The Identity Service creates a UserIdentity record with a UUID and associates it with the organization.
User logs in via Auth0
The user authenticates through Auth0. Auth0's custom action maps their login credential to the NetFoundry identity UUID and injects it as https://netfoundry.io/service-identity/id in the Auth0 JWT.
Grants are assigned to the identity UUID
An admin grants the new user a Standard Role on the organization resource path. From this point, every time the user calls POST /exchange, their NF JWT will embed their granted role references.
Grant Types — How Permissions Are Assigned
A Grant is an assignment of a permission to an identity. There are five grant types, evaluated in priority order. The first type that authorizes the action wins.
| Priority | Type | Scope | Use case |
|---|---|---|---|
| 1 (highest) | SuperUser | Global or per-domain | Full unrestricted access — NFADMIN root accounts |
| 2 | PublicGrant | Global (all identities) | Resources readable by everyone (Geo Regions, Data Centers) |
| 3 | IdentityResourceAction | Per identity, per action, per path | Fine-grained single-action grants |
| 4 | CustomRole | Per identity, per path | Curated sets of actions (NFSUPPORT, service accounts) |
| 5 (lowest) | StandardRole | Per identity, per resource path | End-user roles: Organization Admin, Network Admin, Read-Only |
SuperUser Grant
🔱 SuperUser
A SuperUser grant gives an identity complete, unrestricted access to everything. No path check is performed. No action check is performed. If you have a SuperUser grant, you can do anything.
Database structure
SuperUser {
id binary(16) -- grant UUID
identityId binary(16) -- the identity this applies to
domainId binary(16) -- NULL = all domains; set = only that domain
}
- If
domainIdisNULL→ super-user over everything (all domains, all resources) - If
domainIdis set → super-user only within that domain
Example: Creating a SuperUser via SQL
-- Give Alice's identity full super-user access across all domains
SET @identityId = UNHEX('87756049EBD311EAB3FE0242C0A8900C'); -- Alice's identity UUID
INSERT INTO SuperUser (id, identityId, domainId) VALUES (
UNHEX(REPLACE(UUID(), '-', '')),
@identityId,
null -- null = all domains
);
Example: Creating a SuperUser via REST API
POST /auth/v1/super-users
Authorization: Bearer <NF JWT of an existing super-user>
Content-Type: application/json
{
"identityId": "87756049-ebd3-11ea-b3fe-0242c0a8900c",
"domainId": null
}
Public Grant
🌐 PublicGrant
A PublicGrant assigns a specific action on a resource type to all authenticated identities simultaneously — no individual assignment needed. If an action is publicly granted, every logged-in user has it.
Database structure
PublicGrant {
id binary(16)
resourceActionId binary(16) -- which action is being granted
isAnonymous BIT -- true = even unauthenticated callers get this
}
What is publicly granted by default?
| Resource Type | Action | Why |
|---|---|---|
geo-region | read | Console UI needs to show geo regions to everyone for network creation |
data-center | read | Same — data center listing is public info |
domain | read | Auth clients need to discover domain definitions |
resource-type | read | Auth clients need to know what resource types exist |
resource-action | read | Auth clients need to know what actions exist |
PublicGrantResourceAncestry table but in practice all current public grants have no ancestry rows — meaning they apply to ALL instances of the resource type globally.
Example: Creating a Public Grant via REST API
-- Make "read" on a new resource type publicly accessible to all authenticated users
POST /auth/v1/public-grants
Authorization: Bearer <NF JWT>
{
"resourceActionId": "the-uuid-of-the-read-action-on-your-resource-type",
"isAnonymous": false
}
Identity-Resource Action Grant
🎯 IdentityResourceAction
The most surgical grant type. Assigns a single specific action on a specific resource path to a specific identity.
Use this when you need to grant one precise permission without giving a whole role. For example: "Alice can reset MFA for users in organization ABC, but nothing else."
Example: Grant Alice permission to reset MFA for a specific org
POST /identity-resource-actions
Authorization: Bearer <NF JWT>
{
"identityId": "alice-uuid",
"resourceActionId": "uuid-of-update-reset-mfa-action",
"resourceIdPath": [
{ "resourceTypeId": "uuid-of-organization-resource-type", "resourceId": "org-abc-uuid" }
]
}
This gives Alice update-reset-mfa on all user-identity resources under organization/org-abc.
Example: Grant a service identity permission to create networks
POST /identity-resource-actions
Authorization: Bearer <NF JWT>
{
"identityId": "network-service-identity-uuid",
"resourceActionId": "uuid-of-network-create-action",
"resourceIdPath": [] // empty path = permission on ALL networks globally
}
Custom Role
🧩 CustomRole
A Custom Role is a named, curated set of actions. You define which actions it contains once, then grant it to multiple identities over any resource path.
Structure
CustomRole {
id binary(16)
name varchar(256) -- e.g., "Read-Only, ALL Auth Domain Resources"
}
CustomRoleAction {
customRoleId → CustomRole.id
resourceActionId → ResourceAction.id
-- optional: CustomRoleActionResourceAncestry for path scoping per action
}
IdentityCustomRole {
identityId binary(16) -- who gets this role
customRoleId binary(16) -- which role
-- optional: ancestry rows for path scoping
}
Well-Known Custom Roles
| Name | Static ID | Purpose |
|---|---|---|
| Read-Only, ALL Auth Domain Resources | 233ae92e-4b5d-4cab-9549-5d55fd1cbf6c |
Given to all NF microservices that need to read auth data locally |
| NF Support (Intervention) | 97315ed6-2fc7-4a40-b7ea-abe53cc6a188 |
NF Support staff who need to intervene in customer environments |
| NF Support (Observation) | see migrations | NF Support staff who can only view, not modify |
Example: Creating a Custom Role and granting it to Bob
// Step 1: Create the custom role
POST /auth/v1/custom-roles
{
"name": "Network Read plus Edge Router Heal"
}
// Response: { "id": "new-custom-role-uuid", "name": "..." }
// Step 2: Add actions to the role
POST /auth/v1/custom-role-actions
{
"customRoleId": "new-custom-role-uuid",
"resourceActionId": "uuid-of-network-read-action"
}
POST /auth/v1/custom-role-actions
{
"customRoleId": "new-custom-role-uuid",
"resourceActionId": "uuid-of-edge-router-update-heal-action"
}
// Step 3: Grant the role to Bob over a specific network group
POST /auth/v1/custom-role-grants
{
"identityId": "bob-uuid",
"customRoleId": "new-custom-role-uuid",
"resourceIdPath": [
{ "resourceTypeId": "network-group-type-uuid", "resourceId": "ng-uuid" }
]
}
Standard Role
The most commonly used grant for end-users. Before looking at the DB rows, start with a real-world analogy — it makes every table name obvious.
The Analogy — University Building Management
Think of a university building management system:
| University concept | Auth system concept | DB table |
|---|---|---|
| The university has departments — Physics, Chemistry, CS | Domain — identity, network, auth, billing | Domain |
| Each department has room types — lab, office, lecture hall | ResourceType — organization, network-group, network, endpoint | ResourceType |
| On each room you can perform actions — enter, book, rearrange | ResourceAction — create, read, update, delete, custom | ResourceAction |
| Rules saying "a Lab Manager can rearrange any room in their lab" | ActionInclusion — Admin includes update at and below the grant |
ActionInclusion |
| The "Lab Manager" job description — applies to any lab, written once | StandardRoleType — "Admin on network-group", written once by migration | StandardRoleType |
| "Lab Manager of Room 301" — that title pinned to one specific room | StandardRole — "Admin on ng-acme specifically" | StandardRole + StandardRoleResourceAncestry |
| Alice holds that job — the offer letter is signed | IdentityStandardRole — Alice is assigned that role | IdentityStandardRole |
The Seven Layers — Each One Narrows the Scope
Layer 1 — Domain: The Namespace Fence
A Domain owns a category of resources. Grants in one domain are invisible to checks in another — they are hard walls. The auth check always matches domain code first.
io.netfoundry.identity → Identity Service (orgs, users, API accounts) io.netfoundry.network → Core Service (network-groups, networks, endpoints) io.netfoundry.auth → Auth Service (roles, grants, actions — the model itself) io.netfoundry.billing → Billing Service (billing accounts)
Layer 2 — ResourceType: The Shape of Things Inside a Domain
Resource types form a tree via parentId. The depth of a type in the tree is what drives which includeWhenAbove / At / Below flag applies during a permission check.
network-group (depth 0 — root)
└── network (depth 1)
├── endpoint (depth 2)
├── service (depth 2)
└── appwan (depth 2)
Layer 3 — ResourceAction: The Verbs
Each ResourceType gets a set of actions. Default: create, read, update, delete. Custom actions like ziti-token-exchange or launch-diagnostics are added per type as needed. A create on network is a different row from create on endpoint.
Layer 4 — ActionInclusion: Rules Connecting Verbs to Strategy Names
This table says: for a given action and strategy name ("Admin" or "Read-Only"), should the action be included when the grant is above, at, or below the action's resource type? There are no per-identity or per-resource rows here — these are global rules seeded once by migration.
Action: read, strategy: Admin → above=✅ at=✅ below=✅ Action: create, strategy: Admin → above=❌ at=❌ below=✅ Action: update, strategy: Admin → above=❌ at=✅ below=✅ Action: delete, strategy: Admin → above=❌ at=❌ below=✅ Action: read, strategy: Read-Only → above=✅ at=✅ below=✅
Layer 5 — StandardRoleType: The Job Description
Combines a ResourceType with a strategy name. Answers: "what does it mean to be Admin on a network-group?" No paths, no identities, no resource instances — pure definition. One row per (resource type, strategy) pair. Seeded by migration, never changes.
StandardRoleType { resourceTypeId → network-group, authorizationStrategyName = "Admin" }
StandardRoleType { resourceTypeId → network-group, authorizationStrategyName = "Read-Only" }
StandardRoleType { resourceTypeId → organization, authorizationStrategyName = "Admin" }
StandardRoleType { resourceTypeId → network, authorizationStrategyName = "Admin" }
Layer 6 — StandardRole: The Job Pinned to One Specific Resource
Takes the abstract definition and stamps it onto a real resource UUID. One row per (type, resource) pair. Created when a customer is provisioned — not by migration.
StandardRoleType (Admin on network-group) ← shared by all customers
↓ stamped onto ACME's network-group
StandardRole { standardRoleTypeId → above }
StandardRoleResourceAncestry { resourceId = ng-acme-uuid }
Layer 7 — IdentityStandardRole: The Assignment
The leaf. Connects one identity to one StandardRole. This is the only row that ties a person to a permission. Everything above it is shared infrastructure.
IdentityStandardRole { identityId = alice-uuid, standardRoleId = sr-ng-acme }
Why Each Layer Exists
| Layer | Changes when |
|---|---|
| Domain | A new microservice area is added to the platform |
| ResourceType | A new kind of thing is added to a service |
| ResourceAction | A new capability is added to a resource type |
| ActionInclusion | A policy decision changes — e.g., "Admins should no longer delete their own org" |
| StandardRoleType | A new role concept is needed — e.g., "Billing Admin" |
| StandardRole | A new customer org or network-group is provisioned |
| IdentityStandardRole | A user is granted or revoked access |
The Three Questions a Standard Role Answers
| Question | Table | Created |
|---|---|---|
| "What does Admin on a network-group mean?" | StandardRoleType |
Once — by Flyway migration. Never changes. |
| "Admin on WHICH network-group?" | StandardRole + StandardRoleResourceAncestry |
Once per customer — when a new org/network-group is provisioned. |
| "WHO has it?" | IdentityStandardRole |
Once per grant — when a user is given access. |
Concrete Example — Alice is Admin of ACME's Network Group
Row 1 — StandardRoleType (seeded by migration, exists forever)
StandardRoleType id = fad425e6-... resourceTypeId = <id of "network-group" resource type> authorizationStrategyName = "Admin"
This row says: "the concept of being Admin on a network-group exists." Nothing about ACME. Nothing about Alice. It is a definition — written once by migration, never touched again.
Row 2 — StandardRole + StandardRoleResourceAncestry (created when ACME is provisioned)
StandardRole id = sr-abc-123 standardRoleTypeId = fad425e6-... ← points to Row 1 StandardRoleResourceAncestry standardRoleId = sr-abc-123 resourceId = ng-acme-uuid ← THIS makes it specific to ACME
This row says: "Admin-on-network-group, specifically on ACME's network group." Still nothing about Alice. This row is created once when ACME is provisioned. If Alice, Bob, and Carol all need Admin on ACME — they all share this same row.
Row 3 — IdentityStandardRole (created when Alice is granted access)
IdentityStandardRole id = srg-xyz-789 identityId = alice-uuid ← Alice standardRoleId = sr-abc-123 ← points to Row 2
This row says: "Alice holds the Admin-on-ACME's-network-group role." This is the only row that connects an identity to a permission. Everything above it is shared infrastructure.
Why Three Rows Instead of One?
You might ask: why not just one row that says { alice, Admin, ng-acme }?
Because 50 users might all need Admin on ACME. Without this split you would have 50 copies of "Admin on ng-acme" — the same information repeated 50 times.
With the split:
StandardRole row (shared):
sr-abc-123 → Admin on ng-acme ← 1 row for all 50 people
IdentityStandardRole rows (one per person):
alice → sr-abc-123
bob → sr-abc-123
carol → sr-abc-123
... 50 people ... → sr-abc-123
The "what resource, what strategy" is stored ONCE.
The "who has it" is stored 50 times — because that is the part that actually differs.
- StandardRoleType = "Network Group Admin" — the concept
- StandardRole = "Network Group Admin of ACME" — the concept applied to one specific resource
- IdentityStandardRole = "Alice is Network Group Admin of ACME" — one person holds it
What Happens When a Second Customer (Globex) Is Provisioned?
A new StandardRole row is created for Globex, but it reuses the same StandardRoleType:
StandardRoleType fad425e6 (Admin on network-group) ← shared by everyone, unchanged
↓ ↓
StandardRole sr-abc-123 StandardRole sr-def-456
ancestry: ng-acme-uuid ancestry: ng-globex-uuid
IdentityStandardRole: IdentityStandardRole:
alice → sr-abc-123 dave → sr-def-456
bob → sr-abc-123
Alice and Bob have no access to Globex. Dave has no access to ACME. The StandardRoleType definition is shared by both — but the StandardRole instances are separate, scoped to different resources.
Built-in Standard Role Types
| Domain | Resource Type | Strategy | What it grants |
|---|---|---|---|
| Network | network-group | Admin | Full CRUD on network group and everything below (networks, endpoints, services) |
| Network | network-group | Read-Only | Read access to everything in the network group |
| Network | network | Admin | Full CRUD on that network and its children |
| Network | network | Read-Only | Read-only on that network and its children |
| Identity | organization | Admin | Full CRUD on org and all identities within it |
| Identity | organization | Read-Only | Read access to org and all identities |
| Auth | standard-role | Admin | Can create and grant standard roles |
| Auth | custom-role | Admin | Can create and grant custom roles |
The Full Chain in One Picture
StandardRoleType
(Admin on network-group) ← seeded by migration, shared by all customers
↓ referenced by
StandardRole
(Admin on ng-acme specifically) ← created when ACME is provisioned
↓ referenced by
IdentityStandardRole
(Alice holds that role) ← created when Alice is granted access
Granting Alice Admin on NG-ACME via REST
// Step 1 — Find the StandardRoleType id (or know it already — these are stable)
GET /auth/v1/standard-role-types
// → find id for (network-group, Admin): "fad425e6-..."
// Step 2 — Create (or find) the StandardRole instance for ng-acme
POST /auth/v1/standard-roles
{
"standardRoleTypeId": "fad425e6-...",
"path": "io.netfoundry.network:network-group:ng-acme-uuid"
}
// → { "id": "sr-abc-123", ... }
// Idempotent: if the role for ng-acme already exists, the existing one is returned.
// Step 3 — Assign it to Alice
POST /auth/v1/standard-role-grants
{
"standardRoleId": "sr-abc-123",
"identityId": "alice-uuid"
}
Action Inclusions — How Standard Roles Know What They Grant
Standard Roles don't have an explicit list of actions. Instead, the actions are derived from Action Inclusions — rules that say "if you have the Admin strategy on a resource, you implicitly have the create action on its children."
Action Inclusion Structure
ActionInclusion {
resourceActionId -- the action being conditionally included
authorizationStrategyName -- "Admin" or "Read-Only"
includeWhenAbove BIT -- include this action if grant is ABOVE the resource
includeWhenAt BIT -- include this action if grant is AT the resource
includeWhenBelow BIT -- include this action if grant is BELOW the resource
}
Default inclusion rules (applied to every resource type)
| Strategy | Action | Above | At | Below | Meaning |
|---|---|---|---|---|---|
| Read-Only | read | ✅ | ✅ | ✅ | Can read at any depth |
| Admin | read | ✅ | ✅ | ✅ | Admin can always read |
| Admin | create | ❌ | ❌ | ✅ | Can only create resources below the grant level |
| Admin | update | ❌ | ✅ | ✅ | Can update at and below grant level |
| Admin | delete | ❌ | ❌ | ✅ | Can only delete resources below grant level |
network/xyz (depth 1).
- Can she read the network? ✅ Yes —
read, includeWhenAt=true - Can she create an endpoint under it? ✅ Yes —
create, includeWhenBelow=true(endpoint is below network) - Can she delete the network itself? ❌ No —
delete, includeWhenAt=false - Can she update the endpoint? ✅ Yes —
update, includeWhenBelow=true
Complete Example: How Alice Gets Access to Resources
Alice is a new employee at ACME Corp. She needs to manage networks in ACME's network group NG-ACME. Here is the complete journey from zero to authorized.
Step 1 — Org admin invites Alice
ACME's organization admin calls the Identity Service to create Alice's user account:
POST https://gateway.staging.netfoundry.io/identity/v1/user-identities
Authorization: Bearer <org-admin's NF JWT>
{
"email": "alice@acme.com",
"firstName": "Alice",
"organizationId": "acme-org-uuid"
}
The Identity Service creates Alice's UserIdentity record with UUID alice-uuid-1234 and associates it with ACME's organization.
Step 2 — Admin grants Alice "Network Admin" on NG-ACME
// Find the standard role type for Network Group Admin
GET /auth/v1/standard-role-types
// → finds id: "srt-ng-admin-uuid"
// Create the standard role scoped to NG-ACME
POST /auth/v1/standard-roles
{
"standardRoleTypeId": "srt-ng-admin-uuid",
"resourceIdPath": [
{ "resourceTypeId": "rt-network-group-uuid", "resourceId": "ng-acme-uuid" }
]
}
// → { "id": "sr-alice-ng-acme-uuid" }
// Assign it to Alice
POST /auth/v1/standard-role-grants
{
"identityId": "alice-uuid-1234",
"standardRoleId": "sr-alice-ng-acme-uuid"
}
Step 3 — Alice logs in
Alice's NF JWT now contains (in the grants-standard claim):
{
"sub": "alice-uuid-1234",
"aud": "https://gateway.staging.netfoundry.io/",
"iss": "NetFoundry",
"grants-standard": "[{\"id\":\"sr-alice-ng-acme-uuid\",\"v\":1234567}]",
"grants-public": "[{\"id\":\"pub-geo-read-uuid\",\"v\":...}, ...]",
"grants-custom": "[]",
"grants-action": "[]",
"grants-super": "[]"
}
Step 4 — Alice calls GET /networks
GET https://gateway.staging.netfoundry.io/core/v2/networks Authorization: Bearer <Alice's NF JWT>
Inside the Core Service (Network Service), the @AuthRead annotation triggers:
- Parse Alice's NF JWT → extract
grants-standardclaim → get[{id: sr-alice-ng-acme-uuid, v: 1234567}] - Resolve via
AuthorizationSetBuilder: look upsr-alice-ng-acme-uuid→ StandardRole scoped tonetwork-group/ng-acme-uuidwith strategyAdmin - Expand via ActionInclusion: Admin includes
readat and above → Alice can read atnetwork-group/ng-acme Authorization.isAuthorized(): grant pathnetwork-group/ng-acmeis a prefix of target pathnetwork-group/ng-acme → network/xyz→ ✅ AUTHORIZED- SQL filter applied: query returns only networks under
ng-acme
Step 5 — Alice tries to create an endpoint
POST /core/v2/networks/net-xyz/endpoints
Authorization: Bearer <Alice's NF JWT>
{ "name": "my-laptop" }
Check: Does Alice have create on endpoint under network/net-xyz?
- Grant path:
network-group/ng-acme(depth 0) - Target path:
network-group/ng-acme → network/net-xyz → endpoint/<new>(depth 2) - ActionInclusion: Admin,
create,includeWhenBelow=true— endpoint is BELOW the grant → ✅ AUTHORIZED
Org Admin — What It Covers and What It Does Not
"Org Admin" is not a special system concept. It is simply a StandardRole where the StandardRoleType targets the organization resource type in the io.netfoundry.identity domain with the Admin strategy, scoped to a specific org UUID.
StandardRoleType resourceTypeId = <id of "organization" in io.netfoundry.identity> authorizationStrategyName = "Admin" StandardRole standardRoleTypeId = above ancestry: resourceId = org-acme-uuid ← pinned to ACME's org IdentityStandardRole identityId = alice-uuid standardRoleId = above ← Alice holds it
What Org Admin Actually Grants
The ActionInclusion rules for the Admin strategy determine exactly what actions Alice gets. The organization resource type is at depth 0 (root), so its children — user-identity, api-account-identity, service-identity, invitation — are all "below" it.
| Action | Above | At (org itself) | Below (identities inside) | Result for Alice |
|---|---|---|---|---|
read | ✅ | ✅ | ✅ | Can read the org and all identities inside it |
create | ❌ | ❌ | ✅ | Can create identities inside the org; cannot create the org itself |
update | ❌ | ✅ | ✅ | Can update the org and identities inside |
delete | ❌ | ❌ | ✅ | Can delete identities inside; cannot delete the org itself |
What Org Admin Does NOT Grant — The Two-Domain Split
organization resource type lives in io.netfoundry.identity. Network resources (network-group, network, endpoint) live in io.netfoundry.network. These are completely separate domain trees. A grant in one domain is invisible to the other.
The two domain trees look like this:
io.netfoundry.identity domain io.netfoundry.network domain
───────────────────────────── ──────────────────────────────────
organization (depth 0) network-group (depth 0)
├── user-identity (depth 1) └── network (depth 1)
├── api-account-identity ├── endpoint (depth 2)
├── service-identity ├── service
└── invitation └── appwan
← Org Admin covers this → ← NOT covered by Org Admin →
The link between an org and its network-group (the fact that ACME's org owns ACME's network-group) exists only in the Identity Service and Core Service databases — not in the permissions DB. The permissions DB has no structural link between the two domains.
A Full Customer Admin Needs Two Separate Grants
To give Alice full admin over everything ACME-related, you need one grant in each domain:
Grant 1 — covers identity resources:
IdentityStandardRole
identityId = alice-uuid
standardRoleId → StandardRole(type=OrgAdmin, path=org-acme-uuid)
Grant 2 — covers network resources:
IdentityStandardRole
identityId = alice-uuid
standardRoleId → StandardRole(type=NetworkGroupAdmin, path=ng-acme-uuid)
Alice's NF JWT after login will carry both in grants-standard:
{
"sub": "alice-uuid",
"grants-standard": "[
{\"id\": \"sr-org-acme-uuid\", \"v\": 1700001000},
{\"id\": \"sr-ng-acme-uuid\", \"v\": 1700001100}
]",
...
}
When the Identity Service checks "can Alice create a user under org-acme?" it resolves grant 1.
When the Network Service checks "can Alice create a network under ng-acme?" it resolves grant 2.
Each service looks only at actions belonging to its own domain.
Domain Is the Primary Segregation Boundary
Every authorization check matches three things in order: domain code → resource type code → action code. A grant in io.netfoundry.network can never satisfy a check for an io.netfoundry.identity action — they are hard walls.
| Domain | Protects |
|---|---|
io.netfoundry.identity | Organizations, user-identities, API accounts, service identities, invitations |
io.netfoundry.network | Network-groups, networks, endpoints, appwans, services, edge resources |
io.netfoundry.auth | The permission model itself — standard roles, custom roles, grants, super-users |
io.netfoundry.billing | Billing accounts |
io.netfoundry.management | Management service resources |
io.netfoundry.entitlement | Entitlement and licensing resources |
io.netfoundry.app | Frontdoor / application resources |
StandardRole Grant Lifecycle — Who Creates What and When
This section answers the operational question: when a new customer is onboarded, what actually happens in the DB? Who creates the StandardRole rows? What does the NF JWT carry after all that?
Step 1 — StandardRoleType: Already Exists (migration-seeded)
Nothing to do here. StandardRoleTypes are seeded once by Flyway and never change. Before any customer exists, the definition of "Admin on network-group" already lives in the DB.
Step 2 — StandardRole: Created When a Resource Is Provisioned
When ACME's network-group is created, a provisioning service (an internal service identity) calls:
POST /auth/v1/standard-roles
{
"standardRoleTypeId": "fad425e6-...", // Network Group Admin
"path": "io.netfoundry.network:network-group:ng-acme-uuid"
}
The service implementation is idempotent. If a StandardRole of the same type for the same resource already exists (not deleted), it returns the existing one instead of creating a duplicate:
// StandardRoleService.createStandardRole() — abridged
Set<StandardRole> existing = standardRoleRepository
.selectForUpdate(column("standardRoleTypeId", standardRoleTypeId))
.stream()
.filter(sr -> sr.getPath().getLastPathElement()
.getResourceId().equals(path.getLastPathElement().getResourceId()))
.filter(sr -> sr.getDeletedAt() == null)
.collect(toSet());
if (!existing.isEmpty()) return existing.iterator().next(); // ← return existing, not a new one
return standardRoleRepository.create(...);
Who creates StandardRole instances in production?
Two types of creators appear as the createdBy value on StandardRole rows:
| createdBy UUID | Who | When |
|---|---|---|
0ab1b47d-a5de-4f39-a3bd-7b130cacf401 |
Provisioning service identity (automated) | Automatically when a new org or network-group is created by the Customer Service |
| A human admin UUID | An operator or admin user | Manually via the API when setting up access or during testing |
This is why hundreds of StandardRole records all share the same createdBy — every customer provisioned by the Customer Service triggers the same automated call.
Step 3 — IdentityStandardRole: Created When a User Is Granted Access
POST /auth/v1/standard-role-grants
{
"standardRoleId": "sr-ng-acme-uuid",
"identityId": "alice-uuid"
}
This writes one row to IdentityStandardRole. Also idempotent — if Alice already has this grant, the existing one is returned.
Step 4 — NF JWT: What the Token Carries
When Alice calls POST /exchange, AuthorizationController.addDefaultClaims() runs:
standardRoleService.findStandardRoles(null, alice-uuid, null, null)
This joins IdentityStandardRole → StandardRole and returns all non-deleted StandardRole rows Alice has a grant for. Each result becomes a grant reference — just the ID and the updatedAt epoch-seconds as a version fence.
grants-standard claim holds the StandardRole.id (the path+type tuple shared by all users), not the IdentityStandardRole.id (the per-user assignment row). The identity is already implicit from the JWT sub claim.
{
"sub": "alice-uuid",
"grants-standard": "[{\"id\":\"sr-ng-acme-uuid\", \"v\":1700001000}]",
...
}
The v field is a minimum-version fence: if the cached StandardRole's updatedAt is older than v, the entry must be refreshed before it can be used. This protects against stale cache entries but does not protect against revocation — a revoked grant keeps working until the JWT expires (max 5 minutes).
Summary — What Row Gets Created at Each Stage
| Stage | Trigger | Row created | Shared by |
|---|---|---|---|
| Platform deploy | Flyway migration | StandardRoleType |
All customers forever |
| Customer provisioned | Provisioning service API call | StandardRole + StandardRoleResourceAncestry |
All users of that customer |
| User granted access | Admin API call | IdentityStandardRole |
Just that one user |
How a Created Resource Connects to Alice's Identity
When Alice creates a network net-xyz under ng-acme, zero new rows are written to the permissions DB. There is no row anywhere that says "Alice owns net-xyz". The connection exists across two separate databases and is assembled at check time.
The Two DBs Side by Side
Permissions DB (auth service) Core/Network Service DB
───────────────────────────── ──────────────────────────────────
StandardRole networks table:
path = ng-acme-uuid ←──╮ id = net-xyz
IdentityStandardRole │ networkGroupId = ng-acme-uuid ← parent
identityId = alice-uuid │ createdBy = alice-uuid ← owner
standardRoleId → above │
╯
No net-xyz row here at all
What Happens at Check Time
When Alice calls GET /core/v2/networks/net-xyz, the Core service does three things before the auth check fires:
1. Load the resource from Core DB
Network { id: net-xyz, networkGroupId: ng-acme, createdBy: alice-uuid }
2. Build the target path via ResourceResolver.getResourceIdPath()
The Core service's implementation of ResourceResolver walks up through its own DB to construct the full path:
io.netfoundry.network : network-group/ng-acme → network/net-xyz
This path comes entirely from the Core service DB — the permissions DB has no knowledge of net-xyz.
3. Fetch the owner via ResourceResolver.getOwnerIdentityId()
ownerIdentityId = networks.createdBy = alice-uuid ← from Core DB
4. Authorization.isAuthorized() checks grant path against target path
Alice's grant path: ng-acme (from JWT grants-standard) Target path: ng-acme → net-xyz (built by Core service) Is ng-acme a prefix of ng-acme → net-xyz? ✅ → AUTHORIZED
Where Each Piece of Information Lives
| Information | DB | Queried by |
|---|---|---|
Alice's grant covers ng-acme |
Permissions DB (IdentityStandardRole + StandardRoleResourceAncestry) |
Auth service at POST /exchange — embedded in JWT |
net-xyz belongs to ng-acme |
Core service DB (networks.networkGroupId) |
Core service's ResourceResolver.getResourceIdPath() at request time |
Alice created net-xyz (owner) |
Core service DB (networks.createdBy) |
Core service's ResourceResolver.getOwnerIdentityId() at request time |
| Is Alice authorized? | Computed in memory — no DB read | Authorization.isAuthorized() — grant path prefixes target path |
NFADMIN — The Root Super-User
An NFADMIN user is a NetFoundry internal identity that holds a SuperUser grant with domainId = NULL. This gives them unrestricted access to every resource across every domain. They are essentially the root administrator of the entire platform.
SuperUser table with a null domain ID. It bypasses all authorization path checks entirely.
How to make Alice an NFADMIN user
Via SQL (bootstrap / emergency)
-- Find Alice's identity UUID first
-- SET @identityId = UNHEX('<alice-hex-uuid-without-dashes>');
SET @identityId = UNHEX(REPLACE('alice-uuid-1234', '-', ''));
INSERT INTO SuperUser (id, identityId, domainId) VALUES (
UNHEX(REPLACE(UUID(), '-', '')),
@identityId,
null -- null = super-user over ALL domains
);
Via REST API (requires an existing super-user to call this)
POST /auth/v1/super-users
Authorization: Bearer <existing super-user's NF JWT>
Content-Type: application/json
{
"identityId": "alice-uuid-1234",
"domainId": null
}
What NFADMIN can do
- Create, read, update, delete any resource in any domain
- Grant and revoke any grant type to any identity
- Create organizations, add users to any organization
- Access all networks across all customers
- Manage the authorization model itself (add resource types, actions)
Verifying NFADMIN access in the NF JWT
After Alice logs in with NFADMIN, her NF JWT's grants-super claim will be non-empty:
{
"grants-super": "[{\"id\":\"su-grant-uuid\",\"v\":1234567890}]",
"grants-standard": "[]",
...
}
When the Auth Service resolves this, Authorization.isAuthorized() short-circuits at the SuperUser check and returns true immediately for any action on any resource.
System Bootstrap — Setting Up NFADMIN from Scratch
Bootstrap is the process of initialising the platform for the very first time — before any SuperUser exists, before any org is created, before any login is possible. Because every API call requires an authorised identity, and no authorised identity exists yet, the entire bootstrap must be done via direct DB inserts.
POST /super-users, the caller must already be a SuperUser. But there is no SuperUser yet. Therefore the very first SuperUser can only be created by inserting a row directly into the permissions.SuperUser table.
What Bootstrap Creates
Identity Service DB
identity.realm— Auth0 connection configidentity.tenant— NFADMIN org recordidentity.IdentityProvider— Google OAuth provideridentity.UserIdentity— NFADMIN Root User (system identity)identity.IdentityMapping— links bootstrap person's Google sub to Root User
Auth Service DB (permissions)
- Flyway migrations run automatically on startup — all resource types, actions, standard role types, public grants are seeded
permissions.SuperUser— one manual row linking NFADMIN Root User identity withdomainId = NULL
Complete Bootstrap Sequence
Create the realm (Auth0 connection config)
INSERT INTO identity.realm (id, label)
VALUES (
0xb5f8db6b40f44d1686356c48b592d5cd,
'netfoundry'
);
-- Auth0 tenant URL, client_id, client_secret stored separately
-- in realm config (omitted here for security)
Create the NFADMIN org (tenant)
INSERT INTO identity.tenant (id, realmId, label, name, active)
VALUES (
0x01909a8ea1cb11ef874da29c2afc7246,
0xb5f8db6b40f44d1686356c48b592d5cd, -- realm above
'NFADMIN',
'NetFoundry Administrative Tenant',
true
);
Register the Google identity provider for this org
INSERT INTO identity.IdentityProvider (id, organizationId, auth0ConnectionId, name, auth0ConnectionType, active)
VALUES (
0x019ff31ca1cb11ef874da29c2afc7246,
0x01909a8ea1cb11ef874da29c2afc7246, -- NFADMIN org
'google-oauth2',
'Google Account',
'Social',
true
);
Create the NFADMIN Root User system identity
This is a system identity, not a personal user. It uses a placeholder email and will never log in directly — real people are linked to it via IdentityMapping.
INSERT INTO identity.UserIdentity (id, organizationId, firstName, lastName, email, active)
VALUES (
0x6fa59db2a1cb11ef874da29c2afc7246,
0x01909a8ea1cb11ef874da29c2afc7246, -- NFADMIN org
'',
'NFADMIN Root User',
'no-reply@netfoundry.io', -- placeholder, not a real mailbox
true
);
Link the bootstrap person's Google account
This is what gives a real human access. The auth0UserId comes from Auth0 — it is the sub claim of the person's Google login token.
-- Find the person's Auth0 sub by decoding their Google login token,
-- or from Auth0 Dashboard → User Management → User → User ID field.
INSERT INTO identity.IdentityMapping (id, userIdentityId, auth0UserId, identityProviderId)
VALUES (
UNHEX(REPLACE(UUID(), '-', '')),
0x6fa59db2a1cb11ef874da29c2afc7246, -- NFADMIN Root User
'google-oauth2|116549977878305133269', -- bootstrap person's Google sub
0x019ff31ca1cb11ef874da29c2afc7246 -- Google identity provider
);
IdentityMapping row per person. Each person's Google account is independently mapped to the same NFADMIN Root User identity. To revoke access, delete that person's row only.
Start the Auth Service — Flyway runs automatically
On startup, the Auth Service applies all v2-migration/ Flyway scripts. These seed the entire permissions data model:
- All
Domainrecords (io.netfoundry.network,io.netfoundry.identity,io.netfoundry.auth) - All
ResourceTypetrees (network-group, network, endpoint, organization, user-identity, …) - All
ResourceActionrows (create, read, update, delete + custom actions) - All
StandardRoleTyperows (Admin, Read-Only for every resource type) - All
ActionInclusionrules - All
PublicGrantrows (geo-region read, data-center read, etc.)
No manual SQL needed for any of this — migrations handle it all.
Insert the SuperUser grant (the critical step)
This is the only auth service record that requires a manual DB insert. Utility script: src/main/resources/database/util/insert-superuser.sql
USE permissions;
-- Confirm the identity UUID from the identity service DB first:
-- SELECT HEX(id) FROM identity.UserIdentity WHERE lastName = 'NFADMIN Root User';
SET @identityId = 0x6fa59db2a1cb11ef874da29c2afc7246;
SET @superUserId = UNHEX(REPLACE(UUID(), '-', ''));
INSERT INTO SuperUser (id, identityId, domainId, createdBy)
VALUES (
@superUserId,
@identityId,
NULL, -- NULL = unrestricted access across ALL domains
@identityId -- self-referential for bootstrap
);
-- Verify:
SELECT HEX(id), HEX(identityId), HEX(domainId), createdAt
FROM SuperUser
WHERE identityId = @identityId AND deletedAt IS NULL;
What You Can Do After Bootstrap
Once step 6 is complete, the bootstrap person can log in with their Google account. From that point on, all further setup is done via the API — no more manual DB inserts needed:
| Task | API call |
|---|---|
| Give another person NFADMIN access | Add a new IdentityMapping row (still manual — identity service only) or POST /auth/v1/super-users for a separate identity |
| Create a new customer org | Identity Service API |
| Grant a user org admin | POST /auth/v1/standard-role-grants |
| Create new resource types / actions | Flyway migration SQL (auto-applied on next deploy) |
| Create custom roles (NFSUPPORT etc.) | Flyway migration SQL |
Bootstrap Data Flow
Auth0 config
NFADMIN org
google-oauth2
NFADMIN Root User
person's Google sub
auto on startup
domainId = NULL
NFSUPPORT — Support Staff Roles
NF Support users are NetFoundry employees who need to access customer environments for troubleshooting and operations. Unlike NFADMIN, they do NOT have unrestricted access. Instead they are assigned one of two well-known Custom Roles:
🔧 Intervention Role
Static ID: 97315ed6-2fc7-4a40-b7ea-abe53cc6a188
For support staff who need to actively operate in customer environments. Includes:
- Network / Edge Router: heal, suspend, resume, upgrade, move, backup, restore
- Network Controller: all management operations
- Standard read access across all domains
- Create/delete operations for specific support-only resources
👁️ Observation Role
For support staff who only need to view customer data for diagnostics. Read-only access across all domains with no ability to modify anything.
How to make Alice an NFSUPPORT (Intervention) user
Because the Custom Role already exists with a static ID, all you need to do is grant it to Alice:
POST /auth/v1/custom-role-grants
Authorization: Bearer <NF JWT of an admin who can grant custom roles>
{
"identityId": "alice-uuid-1234",
"customRoleId": "97315ed6-2fc7-4a40-b7ea-abe53cc6a188",
"resourceIdPath": [] // empty = grant applies to ALL resources globally
}
Alice's next login will produce an NF JWT where grants-custom contains a reference to this custom role. Every downstream service that checks her authorization will resolve the role and grant her the support-level permissions.
Key difference: NFADMIN vs NFSUPPORT
| Aspect | NFADMIN | NFSUPPORT Intervention |
|---|---|---|
| Grant type | SuperUser (null domain) | CustomRole (specific actions) |
| Access level | Everything, unconditionally | Curated set of support operations |
| Path scoping | None (bypassed) | Can be scoped to specific customer |
| Auditability | Hard to audit specifics | Specific actions are enumerable |
| Adding new resource | Automatic access | Must explicitly add to Custom Role via migration |
Adding new actions to NFSUPPORT via migration
When a new resource type or custom action is added to the platform, it must be explicitly added to the NFSUPPORT custom roles. This is done via a Flyway migration:
-- V2.XXX.0__grant-new-action-to-nfsupport.sql SET @interventionCustomRoleId = X'97315ed62fc74a40b7eaabe53cc6a188'; SET @domainId = (SELECT id FROM Domain WHERE code = 'io.netfoundry.network'); INSERT IGNORE INTO CustomRoleAction (id, customRoleId, resourceActionId) SELECT UUID(), @interventionCustomRoleId, RA.id FROM ResourceAction RA JOIN ResourceType RT on RA.resourceTypeId = RT.id WHERE RA.code = 'update-new-action' AND RT.code = 'your-new-resource-type' AND RT.domainId = @domainId;
Owner-Identity Dynamic Grants
When Alice has a grant scoped to a resource (e.g., "Network Admin of net-xyz"), and she tries to act on something below that resource (e.g., an endpoint under it), the auth system does not simply check grant path prefix. It also checks whether Alice created the resource she is trying to act on. This is the owner-identity pattern.
Where the Owner Is Stored
When Alice creates an endpoint, the Core service stores createdBy = alice-uuid in its own DB. The permissions DB has no record of this. At check time, the Core service fetches it via ResourceResolver.getOwnerIdentityId() and passes it to the auth check as a Supplier<UUID>.
Authorization.isAuthorized(
alice-uuid, // who is asking
authorizationSet, // her grants from JWT
"io.netfoundry.network", "endpoint", "update", // what she wants to do
targetPath, // path to the endpoint
() -> endpointRepository.findOwnerIdentityId(endpointId) // ← owner supplier
);
The Delta-Depth Rule — When Owner Check Fires
The logic in Authorization.isOwnerIdentityAuthorized() computes the depth difference between the action's resource type and the grant's resource type:
deltaDepth = actionResourceType.getDepth() - grantPath.getLastPathElement().getResourceType().getDepth() // positive → action is BELOW the grant level → owner check applies // zero → action is AT the grant level → no owner check, just authorized // negative → action is ABOVE the grant level → no owner check, just authorized
| Grant at | Action on | deltaDepth | Owner check? |
|---|---|---|---|
network (depth 1) |
network (depth 1) |
0 — at | No — just authorized |
network (depth 1) |
endpoint (depth 2) |
+1 — below | Yes — must be owner |
network-group (depth 0) |
network (depth 1) |
+1 — below | Yes — must be owner |
The Decision Once Owner Check Fires
// from Authorization.isOwnerIdentityAuthorized()
if (deltaDepth <= 0) {
return true; // at or above — owner identity irrelevant
}
// below: permit create, enforce owner match for everything else
if (action.getCode().equals("create") || action.getCode().startsWith("create-")) {
return true; // can always create under a resource in your grant scope
}
return identityId.equals(ownerIdentityIdOfTargetResource);
// alice-uuid == endpoint.createdBy ?
| Scenario | Result |
|---|---|
Action is create or create-* (below grant level) |
✅ Always authorized — you can create resources under your scope |
| Action is non-create, owner of target resource == caller's identity UUID | ✅ Authorized — Alice is acting on her own resource |
| Action is non-create, owner of target resource == Bob's UUID | ❌ Denied — Alice does not own Bob's resource |
Supplier is null (calling code omitted owner resolution) |
❌ Denied — owner-dynamic grants cannot be evaluated without the supplier |
includeWhenBelow — there is no owner check because the grant covers the entire group.
Owner-identity kicks in only when Alice has a narrower grant, e.g., "Network Admin of net-xyz" (depth 1),
and is acting on endpoints (depth 2) under it.
Full Token Lifecycle
Token renewal
NF JWT expires every 5 minutes
↓
Console UI detects 401 response
↓
Calls POST /exchange again with same Auth0 JWT (still valid for 24h)
↓
Gets fresh NF JWT
↓
Retries original request
Auth Service's own token (inter-service)
Auth Service starts up
↓
InterServiceTokenProvider calls POST Auth0 /oauth/token
with client_credentials (clientId + clientSecret)
↓
Gets Auth0 JWT for the service's own identity
Validates it, extracts service-identity UUID
↓
Scheduled refresh 60 minutes before expiry (24h token)
↓
When calling other services: POST /exchange?audience=<target>
Gets NF JWT scoped to that specific target service (aud: target URL)
NF JWT — What's Inside
The NF JWT carries five custom claims, each a JSON-serialized array of {id, v} grant references:
{
// Standard JWT fields
"sub": "alice-uuid-1234", // identity UUID
"iss": "NetFoundry", // always this
"aud": "https://gateway.staging.netfoundry.io/",
"iat": 1779173775,
"exp": 1779173775 + 300, // 5 minutes TTL
// NetFoundry grant claims (compact references, NOT full data)
"grants-public": "[{\"id\":\"pub-uuid\",\"v\":1234}]",
"grants-super": "[]", // empty if not a superuser
"grants-action": "[]",
"grants-custom": "[]",
"grants-standard": "[{\"id\":\"sr-uuid\",\"v\":9876}]"
}
The v field is the grant's updatedAt epoch second (version). When a downstream service resolves grants, it uses this to detect if its local cache is stale — if the cache entry's version is older than v, it fetches fresh data.
| Claim | Type | Contains references to |
|---|---|---|
grants-public | PublicGrant | Public grants (same for all identities) |
grants-super | SuperUser | SuperUser grants held by this identity |
grants-action | ActionGrant | IdentityResourceAction grants held by this identity |
grants-custom | CustomRole | CustomRole grants held by this identity |
grants-standard | StandardRole | StandardRole grants held by this identity |
Complete Login Flow — Prashant Logs into NFADMIN
This is the real end-to-end login flow traced from a live session. It covers every network call the browser makes, what happens server-side (invisible to the browser), and exactly how a Google Workspace account ends up with full platform access.
- Browser / Frontend — React app at
localhost:4200 - API Gateway —
localhost:8080— all traffic passes through here - Identity Service —
/identity/v1/...— manages users, orgs, realms - Auth0 —
netfoundry-sandbox.auth0.com— external identity provider - Auth Service —
localhost:8095— issues NF JWTs, stores grants - Core Service —
/core/v3/...— downstream API that checks NF JWT grants
Phase 1 — UI Initiates Login
① GET /identity/v1/organizations/label/NFADMIN?realm=netfoundry
Browser asks the Identity Service: "What is the NFADMIN org and which Auth0 connection should I use?"
Identity Service DB queries:
SELECT * FROM identity.tenant WHERE label = 'NFADMIN' → id: 01909a8e-a1cb-11ef-874d-a29c2afc7246 → name: NetFoundry Administrative Tenant SELECT * FROM identity.realm WHERE label = 'netfoundry' → id: b5f8db6b-40f4-4d16-8635-6c48b592d5cd → auth0: netfoundry-sandbox.auth0.com
Response: org metadata + which Auth0 tenant/connection to use.
② POST /identity/v1/organizations/authorize-initiate
Request:
{
"tenantLabel": "NFADMIN",
"intermediateReturnUrl": "http://localhost:4200/login",
"realm": "netfoundry"
}
Identity Service:
- Generates a PKCE pair:
code_verifier(kept in memory) +code_challenge = SHA256(code_verifier) - Issues a short-lived state JWT (15 min) — not the NF JWT
State JWT payload (decoded):
{
"iss": "https://gateway.sandbox.netfoundry.io/identity/v1",
"aud": "https://netfoundry-sandbox.auth0.com/",
"flow": "login",
"tenantLabel": "NFADMIN",
"auth0ConnectionIds": ["google-oauth2"],
"redirectUrl": "http://localhost:4200/login",
"exp": iat + 900 ← 15 minutes only
}
Response: { nfToken: "<state JWT>", auth0ConnectionIds: ["google-oauth2"] }
Phase 2 — Auth0 PKCE Authentication
③ POST https://netfoundry-sandbox.auth0.com/usernamepassword/challenge
Browser sends the Auth0 state (MessagePack encoded) to initiate the login challenge:
State (decoded from MessagePack binary):
{
"h": "tQCrBhpi8ipo-y7lWksxANUFk5-V92US", ← session hash (CSRF)
"n": "login",
"tid": "MNi8kowGiRDmov6apBDOOWgWUJyd8NFi", ← Auth0 tenant ID
"cid": "6WXzPe5Zu3Z0VjJDbwZcsZ71JeGRDJOp" ← Auth0 client ID
}
Auth0 Home Realm Discovery (HRD): detects @netfoundry.io domain → Google Workspace connection → redirects browser to Google sign-in.
④ [Google OAuth — browser-level redirect]
Prashant signs in with prashant.savadi@netfoundry.io (Google Workspace account). Google authenticates and returns an auth code to Auth0. Auth0 runs its Rules/Actions to inject custom claims.
⑤ POST https://netfoundry-sandbox.auth0.com/oauth/token PKCE Exchange
Request: grant_type: authorization_code code: sLUoyA1b6in... ← one-time code from Auth0 callback code_verifier: rQusde128ZW... ← PKCE secret (proves same client started login) client_id: 6WXzPe5Zu3Z0Vj... redirect_uri: http://nfadmin.localhost:4200/callback
Auth0 validates:
- Code is valid and unused (one-time use)
SHA256(code_verifier)matches storedcode_challengeclient_idandredirect_urimatch registered values
Response — access_token (24h) payload:
{
"iss": "https://netfoundry-sandbox.auth0.com/",
"sub": "google-oauth2|116549977878305133269", ← Prashant's Google identity
"aud": ["http://localhost:8080/", "...userinfo"],
"https://netfoundry.io/tenant/label": "NFADMIN", ← injected by Auth0 Rule
"https://netfoundry.io/auth0/connectionId": "google-oauth2",
"exp": iat + 86400 ← 24 hours
}
Response — id_token (10h) payload:
{
"sub": "google-oauth2|116549977878305133269",
"email": "prashant.savadi@netfoundry.io",
"given_name": "Prashant",
"family_name": "Savadi"
}
sub) and WHICH org you logged into (tenant/label). It contains no SuperUser, StandardRole, or any permission claims. Those are resolved by the Auth Service.
Phase 3 — Identity Resolution
⑥ GET /identity/v1/realms/label/netfoundry
Frontend confirms realm config. Returns the identity.realm record (id: b5f8db6b...).
⑦ GET /identity/v1/identities/self Browser visible
Frontend asks: "Who am I in this system?" Identity Service looks up the Google sub in identity.IdentityMapping:
SELECT ui.* FROM identity.UserIdentity ui JOIN identity.IdentityMapping im ON im.userIdentityId = ui.id WHERE im.auth0UserId = 'google-oauth2|116549977878305133269'
Response:
{
"id": "6fa59db2-a1cb-11ef-874d-a29c2afc7246", ← Prashant's identityId
"lastName": "NFADMIN Root User",
"email": "no-reply@netfoundry.io",
"identityMappings": [{
"auth0UserId": "google-oauth2|116549977878305133269" ← linked to Prashant's Google
}]
}
identityMapping on the NFADMIN Root User system identity. Prashant IS the NFADMIN Root User. His personal Google account was manually inserted into the DB during bootstrap to give him platform-wide access.
⑧ GET /identity/v1/organizations/self?realm=netfoundry Browser visible
Response:
{
"id": "01909a8e-a1cb-11ef-874d-a29c2afc7246", ← NFADMIN org UUID
"name": "NetFoundry Administrative Tenant",
"label": "NFADMIN",
"identityProviders": [{ "auth0ConnectionId": "google-oauth2" }]
}
⑨ POST /exchange Server-side only — invisible to browser
The Identity Service (not the browser) calls the Auth Service server-to-server with the Auth0 access_token:
POST http://localhost:8095/exchange Authorization: Bearer <Auth0 access_token>
Auth Service (AuthorizationController) processes it:
- Validates Auth0 JWT signature via Auth0's JWKS endpoint
- Extracts
sub = google-oauth2|116549977878305133269 - Resolves via Identity Service →
identityId = 6fa59db2-a1cb-11ef-874d-a29c2afc7246 - Queries
permissionsDB for all grants:
SELECT * FROM SuperUser WHERE identityId = 0x6fa59db2a1cb11ef874da29c2afc7246 AND deletedAt IS NULL → row found: domainId = NULL ← full platform SuperUser
Issues NF JWT (5 min TTL):
{
"sub": "6fa59db2-a1cb-11ef-874d-a29c2afc7246", ← UUID now, not Google sub
"iss": "NetFoundry",
"aud": "http://localhost:8080/",
"iat": <now>,
"exp": <now + 300>,
"grants-super": "[{\"id\":\"<superUserId>\",\"v\":<epoch>}]",
"grants-public": "[...]",
"grants-standard": "[]",
"grants-custom": "[]",
"grants-action": "[]"
}
Phase 4 — Authorization Resolution (UI Rendering)
⑩ GET /auth/v1/authorizations?identityId=6fa59db2-a1cb-11ef-874d-a29c2afc7246 Browser visible
Frontend asks the Auth Service for Prashant's full permission set so the UI knows what to show/hide. Auth Service queries permissions DB directly:
PublicGrants → all public grants (geo-regions, data-centers, etc.) SuperUser → domainId=NULL found → full platform access StandardRoles → none (SuperUser covers everything) CustomRoles → none ActionGrants → none
Response: full AuthorizationSet JSON. UI renders full admin view of NFADMIN with all features unlocked.
Phase 5 — Downstream API Calls via Gateway
⑪ GET /core/v3/networks?... Browser visible
Browser sends Auth0 token to gateway:
GET http://localhost:8080/core/v3/networks Authorization: Bearer <Auth0 token> ← what browser sends to gateway
The API Gateway intercepts and exchanges Auth0 token → NF JWT (server-side) before forwarding:
Gateway → POST /exchange → NF JWT Gateway → Core Service GET /core/v3/networks Authorization: Bearer <NF JWT> ← NF JWT, not Auth0 token
Core Service uses the clientv2 library (AuthorizationContext.createContext(nfJwt)):
- Parses
subas UUID →6fa59db2...✓ (Auth0 subgoogle-oauth2|...would fail here) - Reads
grants-superclaim → SuperUser withdomainId=NULL Authorization.isAuthorized()short-circuits →truefor everything- Returns all networks across all customers
Complete Flow Summary
resolve org + realm
PKCE + state JWT
HRD → Google
user signs in
PKCE exchange → Auth0 JWT
server-side: Auth0 JWT → NF JWT
UI permission set
Auth0 JWT → NF JWT per request
Why Prashant Has Full Access — The Chain
prashant.savadi@netfoundry.io (Google Workspace)
│
│ Auth0 sub: google-oauth2|116549977878305133269
│ ↓ identity.IdentityMapping (manually inserted during bootstrap)
│
├── UserIdentity: 6fa59db2-a1cb-11ef-874d-a29c2afc7246
│ lastName: "NFADMIN Root User" ← system identity, email = no-reply@netfoundry.io
│
│ ↓ permissions.SuperUser (inserted via insert-superuser.sql utility)
│
└── SuperUser: identityId = 6fa59db2..., domainId = NULL
→ unrestricted access to ALL domains, ALL resources, ALL actions
How the Bootstrap Was Done (Manual DB Inserts)
Step 1 — Identity Service DB
-- Create the NFADMIN Root User system identity
INSERT INTO identity.UserIdentity (id, organizationId, lastName, email, active)
VALUES (
0x6fa59db2a1cb11ef874da29c2afc7246,
0x01909a8ea1cb11ef874da29c2afc7246, -- NFADMIN org
'NFADMIN Root User',
'no-reply@netfoundry.io', -- placeholder, not a real mailbox
true
);
-- Link Prashant's Google account to this identity
INSERT INTO identity.IdentityMapping (id, userIdentityId, auth0UserId, identityProviderId)
VALUES (
0x6fba6580a1cb11ef874da29c2afc7246,
0x6fa59db2a1cb11ef874da29c2afc7246, -- NFADMIN Root User above
'google-oauth2|116549977878305133269', -- Prashant's Google sub from Auth0
0x019ff31ca1cb11ef874da29c2afc7246 -- Google identity provider record
);
Step 2 — Auth Service DB (permissions schema)
-- From: src/main/resources/database/util/insert-superuser.sql
-- First find the identity UUID:
-- SELECT HEX(id) FROM identity.UserIdentity WHERE lastName = 'NFADMIN Root User';
SET @identityId = 0x6fa59db2a1cb11ef874da29c2afc7246;
INSERT INTO SuperUser (id, identityId, domainId, createdBy)
VALUES (
UNHEX(REPLACE(UUID(), '-', '')),
@identityId,
NULL, -- NULL = super-user over ALL domains
@identityId -- created by itself (bootstrap)
);
IdentityMapping row linking their Google (or other) Auth0 sub to the same UserIdentity UUID. No SuperUser insert needed — the existing one already covers it.
| Token | Issued by | Contains | Used for | TTL |
|---|---|---|---|---|
| Auth0 access_token | Auth0 | sub (Google), tenant/label |
Call Identity Service + Gateway entry point | 24h |
| Auth0 id_token | Auth0 | Name, email, picture | UI profile display only | 10h |
| State JWT (nfToken) | Identity Service | tenantLabel, auth0ConnectionIds, redirectUrl | Login flow context for Auth0 | 15 min |
| NF JWT | Auth Service | UUID sub + grants-super/standard/custom/action/public | All downstream API authorization checks | 5 min |
How to Add a New Resource Type
Adding a new resource type means any service can now have authorization checks for that resource. Here is the complete process:
Create a Flyway migration file
Create V2.<next>.0__add-my-resource-resource-and-actions.sql in src/main/resources/database/v2-migration/
USE permissions;
-- 1. Find the parent domain and parent resource type
SET @domainId = (SELECT id FROM Domain WHERE code = 'io.netfoundry.network');
SET @parentId = (SELECT id FROM ResourceType WHERE code = 'network' AND domainId = @domainId);
-- 2. Create the new resource type
INSERT INTO ResourceType (id, domainId, name, code, parentId) VALUES
(UNHEX(REPLACE(UUID(),'-','')), @domainId, 'My Resource', 'my-resource', @parentId);
SET @myResourceTypeId = (SELECT id FROM ResourceType WHERE code = 'my-resource' AND domainId = @domainId);
-- 3. Create standard CRUD actions
INSERT INTO ResourceAction (id, resourceTypeId, name, code) VALUES
(UNHEX(REPLACE(UUID(),'-','')), @myResourceTypeId, 'Create', 'create'),
(UNHEX(REPLACE(UUID(),'-','')), @myResourceTypeId, 'Read', 'read'),
(UNHEX(REPLACE(UUID(),'-','')), @myResourceTypeId, 'Update', 'update'),
(UNHEX(REPLACE(UUID(),'-','')), @myResourceTypeId, 'Delete', 'delete');
-- 4. Create action inclusions so Standard Roles work correctly
INSERT INTO ActionInclusion (id, resourceActionId, authorizationStrategyName,
includeWhenAbove, includeWhenAt, includeWhenBelow)
SELECT UNHEX(REPLACE(UUID(),'-','')), id, 'Read-Only', 1, 1, 1
FROM ResourceAction WHERE resourceTypeId = @myResourceTypeId AND code = 'read';
INSERT INTO ActionInclusion (id, resourceActionId, authorizationStrategyName,
includeWhenAbove, includeWhenAt, includeWhenBelow)
SELECT UNHEX(REPLACE(UUID(),'-','')), id, 'Admin', 1, 1, 1
FROM ResourceAction WHERE resourceTypeId = @myResourceTypeId AND code = 'read';
INSERT INTO ActionInclusion (id, resourceActionId, authorizationStrategyName,
includeWhenAbove, includeWhenAt, includeWhenBelow)
SELECT UNHEX(REPLACE(UUID(),'-','')), id, 'Admin', 0, 0, 1
FROM ResourceAction WHERE resourceTypeId = @myResourceTypeId AND code = 'create';
INSERT INTO ActionInclusion (id, resourceActionId, authorizationStrategyName,
includeWhenAbove, includeWhenAt, includeWhenBelow)
SELECT UNHEX(REPLACE(UUID(),'-','')), id, 'Admin', 0, 1, 1
FROM ResourceAction WHERE resourceTypeId = @myResourceTypeId AND code = 'update';
INSERT INTO ActionInclusion (id, resourceActionId, authorizationStrategyName,
includeWhenAbove, includeWhenAt, includeWhenBelow)
SELECT UNHEX(REPLACE(UUID(),'-','')), id, 'Admin', 0, 0, 1
FROM ResourceAction WHERE resourceTypeId = @myResourceTypeId AND code = 'delete';
Grant NFSUPPORT access (if needed)
-- In a follow-up migration: V2.<next+1>.0__grant-my-resource-to-nfsupport.sql
SET @interventionCustomRoleId = X'97315ed62fc74a40b7eaabe53cc6a188';
INSERT IGNORE INTO CustomRoleAction (id, customRoleId, resourceActionId)
SELECT UUID(), @interventionCustomRoleId, RA.id
FROM ResourceAction RA JOIN ResourceType RT on RA.resourceTypeId = RT.id
WHERE RA.code IN ('read', 'create', 'update', 'delete')
AND RT.code = 'my-resource' AND RT.domainId = @domainId;
Use @AuthAction in the downstream service controller
@PostMapping("/my-resources")
public MyResource create(
@AuthCreate(on = MyResource.class)
@RequestParam UUID networkId
) {
// If we reach here, the caller is authorized
}
How to Add a Custom Action to an Existing Resource Type
Example: Adding a "publish" action to the network resource type
-- V2.XXX.0__add-network-publish-action.sql
USE permissions;
SET @domainId = (SELECT id FROM Domain WHERE code = 'io.netfoundry.network');
SET @networkTypeId = (SELECT id FROM ResourceType WHERE code = 'network' AND domainId = @domainId);
-- Create the new custom action
INSERT INTO ResourceAction (id, resourceTypeId, name, code) VALUES
(UNHEX(REPLACE(UUID(),'-','')), @networkTypeId, 'Publish', 'publish');
SET @publishActionId = (SELECT id FROM ResourceAction WHERE code = 'publish' AND resourceTypeId = @networkTypeId);
-- Decide: should Admin strategy include this? Yes, at the network level and below.
INSERT INTO ActionInclusion (id, resourceActionId, authorizationStrategyName,
includeWhenAbove, includeWhenAt, includeWhenBelow) VALUES
(UNHEX(REPLACE(UUID(),'-','')), @publishActionId, 'Admin', 0, 1, 0);
-- Grant it to NFSUPPORT intervention as well
SET @interventionCustomRoleId = X'97315ed62fc74a40b7eaabe53cc6a188';
INSERT IGNORE INTO CustomRoleAction (id, customRoleId, resourceActionId) VALUES
(UNHEX(REPLACE(UUID(),'-','')), @interventionCustomRoleId, @publishActionId);
How to Create a New Standard Role Type
Standard Role Types are templates. Once created, instances can be granted to any identity over any resource path of the matching resource type.
Example: Create a "Contributor" role type for networks
-- V2.XXX.0__add-network-contributor-standard-role-type.sql
USE permissions;
SET @domainId = (SELECT id FROM Domain WHERE code = 'io.netfoundry.network');
SET @networkTypeId = (SELECT id FROM ResourceType WHERE code = 'network' AND domainId = @domainId);
-- Create the StandardRoleType
INSERT INTO StandardRoleType (id, resourceTypeId, authorizationStrategyName) VALUES
(UNHEX(REPLACE(UUID(),'-','')), @networkTypeId, 'Contributor');
-- Now create ActionInclusions for the 'Contributor' strategy
-- Contributor can read and update, but not create or delete
SET @readActionId = (SELECT id FROM ResourceAction WHERE code = 'read' AND resourceTypeId = @networkTypeId);
SET @updateActionId = (SELECT id FROM ResourceAction WHERE code = 'update' AND resourceTypeId = @networkTypeId);
INSERT INTO ActionInclusion (id, resourceActionId, authorizationStrategyName,
includeWhenAbove, includeWhenAt, includeWhenBelow) VALUES
(UNHEX(REPLACE(UUID(),'-','')), @readActionId, 'Contributor', 1, 1, 1),
(UNHEX(REPLACE(UUID(),'-','')), @updateActionId, 'Contributor', 0, 1, 1);
After this migration runs, you can grant this role type to any identity via the REST API:
// Find the new StandardRoleType
GET /auth/v1/standard-role-types
// → { "id": "srt-contributor-uuid", "authorizationStrategyName": "Contributor", ... }
// Create an instance scoped to a specific network
POST /auth/v1/standard-roles
{
"standardRoleTypeId": "srt-contributor-uuid",
"resourceIdPath": [
{ "resourceTypeId": "rt-ng-uuid", "resourceId": "ng-abc-uuid" },
{ "resourceTypeId": "rt-network-uuid", "resourceId": "net-xyz-uuid" }
]
}
// Grant to Alice
POST /auth/v1/standard-role-grants
{ "identityId": "alice-uuid", "standardRoleId": "sr-new-uuid" }
How to Create a Custom Role
Custom Roles are best for service accounts and support staff who need a specific, non-standard set of permissions that doesn't map to a neat "Admin" or "Read-Only" strategy.
Create the Custom Role
POST /auth/v1/custom-roles
{ "name": "Billing Read-Only" }
// → { "id": "cr-billing-ro-uuid" }
Add actions to it
// First, find the action UUIDs you need
GET /auth/v1/resource-actions?resourceTypeCode=billing-account&code=read
// → { "id": "ra-billing-read-uuid" }
POST /auth/v1/custom-role-actions
{
"customRoleId": "cr-billing-ro-uuid",
"resourceActionId": "ra-billing-read-uuid",
"resourceIdPath": [] // empty = applies to all billing accounts
}
Grant it to identities
POST /auth/v1/custom-role-grants
{
"identityId": "billing-service-identity-uuid",
"customRoleId": "cr-billing-ro-uuid",
"resourceIdPath": [] // no path scoping = global
}
Complete Checklist: Granting a New User Access
Here is the complete checklist to configure a new user from zero to having meaningful API access:
| # | Step | Service | API |
|---|---|---|---|
| 1 | Create the organization (if new customer) | Identity Service | POST /identity/v1/organizations |
| 2 | Create the user identity under the org | Identity Service | POST /identity/v1/user-identities |
| 3 | Find or create the Standard Role Type needed | Auth Service | GET /auth/v1/standard-role-types |
| 4 | Create a Standard Role instance scoped to the right path | Auth Service | POST /auth/v1/standard-roles |
| 5 | Grant the Standard Role to the identity | Auth Service | POST /auth/v1/standard-role-grants |
| 6 | User logs in via Auth0 → POST /exchange |
Auth Service | Console UI initiates automatically |
| 7 | Verify by calling GET /authorizations |
Auth Service | GET /auth/v1/authorizations?identityId=<uuid> |
Verifying Alice's authorization set
GET /auth/v1/authorizations?identityId=alice-uuid-1234
Authorization: Bearer <any valid NF JWT>
// Response:
{
"identityId": "alice-uuid-1234",
"grants": {
"io.netfoundry.network/network-group/read": [
"network-group/ng-acme-uuid"
],
"io.netfoundry.network/network/read": [
"network-group/ng-acme-uuid"
],
"io.netfoundry.network/endpoint/create": [
"network-group/ng-acme-uuid"
],
...
}
}
API Reference Summary
Base URL: https://gateway.staging.netfoundry.io/auth/v1/
All endpoints require Authorization: Bearer <NF JWT>.
| Method | Endpoint | Description |
|---|---|---|
| POST | /exchange | Exchange Auth0 JWT for NF JWT |
| POST | /masquerade | Issue NF JWT for a specific identity (service-to-service) |
| GET | /authorizations | Get full authorization set for an identity |
| GET | /auth/jwks | Get RSA public keys for NF JWT validation |
| GET | /domains | List all domains |
| GET | /resource-types | List resource types |
| GET | /resource-actions | List resource actions |
| POST | /super-users | Create a SuperUser grant |
| DELETE | /super-users/{id} | Remove a SuperUser grant |
| POST | /public-grants | Create a public grant |
| POST | /identity-resource-actions | Create a direct action grant |
| POST | /custom-roles | Create a custom role |
| POST | /custom-role-actions | Add an action to a custom role |
| POST | /custom-role-grants | Grant a custom role to an identity |
| GET | /standard-role-types | List standard role type templates |
| POST | /standard-roles | Create a standard role instance (scoped) |
| POST | /standard-role-grants | Assign a standard role to an identity |
| DELETE | /standard-role-grants/{id} | Revoke a standard role from an identity |
Database Schema Overview
The authorization service uses a MySQL database named permissions. All tables use binary(16) UUIDs as primary keys and carry createdAt, updatedAt, and deletedAt columns — the service uses soft-deletes exclusively. A non-null deletedAt means the row is logically deleted but physically retained for history.
The schema falls into four conceptual groups:
Metadata / Definition
Describes what can be authorized: domains, resource types, resource actions, action inclusion rules, and standard role type templates. These rows are stable — inserted by Flyway migrations and rarely changed.
Grant Records
Assigns permissions to identities: SuperUser, PublicGrant, IdentityResourceAction, CustomRole/IdentityCustomRole, StandardRole/IdentityStandardRole. These are the runtime permission data.
Ancestry / Path
Stores the hierarchical ResourceIdPath (chain of resourceType+UUID pairs) that scopes every grant and custom-role action. Each grant table has a companion *ResourceAncestry table.
Custom Role Definition
The named action set that forms a Custom Role: CustomRole, CustomRoleAction, and CustomRoleActionResourceAncestry.
InMemoryMarkDeleteBeanRepository instances. Every read operation hits in-memory. Writes go to MySQL first, then the cache is refreshed. The HandlerInterceptor on every HTTP request calls checkVersions() which invalidates the cache if the DB has changed since the last poll.
DB Design — Four Conceptual Layers
The permissions schema is best understood as four stacked layers. Each layer depends on the one above it, and together they answer a single question: "Does identity X have permission to perform action Y on resource Z?"
Layer 1 — Permission Vocabulary
The static catalog of what can possibly be authorized. Populated by Flyway migrations at deploy time. Never modified at runtime.
| Table | Purpose |
|---|---|
Domain | Namespace, e.g. io.netfoundry.network |
ResourceType | Tree of resource classes with depth + parent |
ResourceAction | Actions (read/create/update/delete/custom) per type |
ActionInclusion | Rules: which actions a strategy grants above/at/below the grant depth |
Layer 2 — Role Templates
Reusable role definitions. Describe what a role grants before any identity is involved. Also populated by migrations.
Pattern: Template + Flyweight — define once, stamp onto any resource, assign to any identity. Same as AWS IAM Policies / Kubernetes ClusterRoles.
| Table | Role | Purpose |
|---|---|---|
StandardRoleType | Role Definition | Template: Admin/Read-Only/Contributor for a given ResourceType |
StandardRole + Ancestry | Role Instance | One stamping of the template onto a specific resource path |
IdentityStandardRole | Role Assignment | Which identity holds that instance |
CustomRole | Role Definition | Named bag of explicit actions (e.g., NFSUPPORT Intervention) |
CustomRoleAction | — | Individual action rows inside a CustomRole |
Layer 3 — Grant Assignments
Runtime data: binds an identity to a permission. These rows are what end up as claim references in the NF JWT.
| Table | Binds |
|---|---|
SuperUser | Identity → all actions in a domain (or all domains) |
PublicGrant | All identities → a single action globally |
IdentityResourceAction | Identity → one specific action on a path |
IdentityCustomRole | Identity → a CustomRole (on a path) |
IdentityStandardRole | Identity → a StandardRole instance (on a path) |
Layer 4 — Ancestry / Path Scoping
Stores the hierarchical ResourceIdPath as a linked-list chain, one row per path element. Scopes every grant and custom-role action to a specific resource subtree.
| Table | Scopes |
|---|---|
StandardRoleResourceAncestry | StandardRole → subtree path |
CustomRoleActionResourceAncestry | Each CustomRoleAction → subtree path |
IdentityResourceActionResourceAncestry | Direct action grant → subtree path |
PublicGrantResourceAncestry | Public grant → subtree path (usually empty = global) |
How a "Network Admin on NG-1" is stored across all four layers
Domain:
io.netfoundry.networkResourceType:
network-group (depth 0) → network (depth 1) → endpoint (depth 2)ResourceAction:
network-group.read, network.create, network.read, …ActionInclusion:
Admin, network.read → includeWhenAbove=true, At=true, Below=trueLayer 2 — Templates
StandardRoleType:
{ resourceType=network-group, strategy=Admin }Layer 3 — Grant Assignment
StandardRole:
{ id=sr-ng1-admin, standardRoleTypeId=srt-ng-admin }IdentityStandardRole:
{ identityId=alice-uuid, standardRoleId=sr-ng1-admin }Layer 4 — Path Scoping
StandardRoleResourceAncestry:
{ standardRoleId=sr-ng1-admin, resourceType=network-group, resourceId=NG-1-uuid, childAncestryId=null }→ reconstructed at runtime as: ResourceIdPath[ network-group/NG-1-uuid ]
What the NF JWT carries from these layers
When Alice calls POST /exchange, the Auth Service walks Layer 3 (her grant assignments), collects each grant's ID and its updatedAt timestamp, and embeds them as compact references in the NF JWT. No Layer 1, 2, or 4 data enters the token — only IDs and versions.
{
"sub": "alice-uuid",
"grants-standard": "[{\"id\":\"sr-ng1-admin\",\"lm\":1692000000000}]", // ← Layer 3 ID only
"grants-public": "[{\"id\":\"pg-geo-read\",\"lm\":...}]",
"grants-super": "[]",
"grants-action": "[]",
"grants-custom": "[]"
}
lm / lastModified) is a cache invalidation signal
When a downstream service resolves sr-ng1-admin from its local in-memory cache, it compares the cached updatedAt with the JWT's lm value. If the cached version is older, the cache entry is refreshed from MySQL before the authorization decision. This gives consistency without per-request DB queries.
How downstream services reconstruct full permissions from those IDs
Resolve Layer 3 → Layer 2
AuthorizationSetBuilder looks up sr-ng1-admin in the in-memory StandardRoleRepository. It finds the row and follows standardRoleTypeId → StandardRoleType{ network-group, Admin }.
Resolve Layer 4 → ResourceIdPath
The ancestry service reconstructs the path linked-list for sr-ng1-admin: one ancestry row with resourceType=network-group, resourceId=NG-1-uuid → ResourceIdPath[ network-group/NG-1 ].
Expand Layer 1 → Concrete action list
For every ResourceAction in the system, the builder checks ActionInclusion rows for strategy Admin. For each action, the grant depth (0 = network-group) is compared to the action's resource type depth to determine if includeWhenAbove, includeWhenAt, or includeWhenBelow applies.
Store in AuthorizationSet
Result is a nested map: domain → resourceType → action → { grantId → [paths] }. This map is what Authorization.isAuthorized() queries at check time — pure in-memory, nanosecond-scale.
Metadata Tables
These tables form the authoritative catalog of everything that can be authorized. They are populated once by Flyway migrations and act as read-only reference data at runtime.
Domain
| Column | Type | Description |
|---|---|---|
id | binary(16) PK | UUID |
name | varchar(256) | Human-readable name, e.g. Networks |
code | varchar(128) UNIQUE | Dot-notation code used in authorization checks and JWT claims, e.g. io.netfoundry.network |
createdAt / updatedAt / deletedAt | DATETIME(6) | Lifecycle timestamps |
Purpose: A Domain is the top-level namespace for a cluster of related resource types. The auth service ships with three fixed domains:
io.netfoundry.identity— Organizations and Identities (users, api accounts, service identities)io.netfoundry.network— Networks (network groups, networks, endpoints, appwans, …)io.netfoundry.auth— Auth domain itself (super-users, custom roles, standard roles — meta-permissions)
Use case: When the code calls Authorization.isAuthorized(..., "io.netfoundry.network", "network", "read", ...) the domain code io.netfoundry.network is the first lookup key into the in-memory authorization set.
Add a new domain? Insert a row via a Flyway migration and then add ResourceType rows pointing at it.
ResourceType
| Column | Type | Description |
|---|---|---|
id | binary(16) PK | UUID |
domainId | binary(16) FK → Domain | Which domain this type belongs to |
name | varchar(256) | Human-readable, e.g. Network |
code | varchar(64) | Kebab-case code used in checks, e.g. network |
parentId | binary(16) FK → ResourceType (NULLABLE) | Self-referential parent — forms the resource tree. null = root type |
Purpose: Defines the resource hierarchy. The tree structure is critical because it determines grant path depth — a grant at network-group depth covers all networks beneath it.
Example tree for io.netfoundry.network:
geo-region (depth 0, root, parentId = null) data-center (depth 0, root, parentId = null) network-group (depth 0, root, parentId = null) ├── network (depth 1, parentId = network-group) │ ├── endpoint (depth 2) │ ├── appwan (depth 2) │ ├── service (depth 2) │ └── edge-router (depth 2) ├── azure-subscription (depth 1) └── netflow-collector (depth 1)
Depth is derived at runtime from the tree structure (root = 0). It is used by isOwnerIdentityAuthorized() to compute the delta between the grant depth and the action's target resource type depth.
Use case: A grant scoped to network-group/NG-1 covers all networks, endpoints, and appwans under NG-1 — because their parentId chains lead back to network-group.
ResourceAction
| Column | Type | Description |
|---|---|---|
id | binary(16) PK | UUID |
resourceTypeId | binary(16) FK → ResourceType | Which resource type this action applies to |
name | varchar(256) | Human-readable, e.g. Create |
code | varchar(64) | Action code used in checks: create, read, update, delete, or custom codes like update-heal, update-resume |
Purpose: The atomic unit of permission. Every permission check is ultimately about whether an identity has a specific ResourceAction on a specific resource path.
Standard actions (create, read, update, delete) are created for every resource type in the domain population migrations. Custom actions are added in later migrations when a service needs a non-CRUD operation.
Examples of custom actions:
update-healonedge-router— trigger healingupdate-resume,update-suspendonnetworkupdate-versiononedge-routercreate-client-tokenonapi-account-identityupdate-migrateonnetwork
In code: When a downstream service calls Authorization.isAuthorized(..., "network", "update-heal", targetPath, ...), the action code update-heal is matched against this table's rows at runtime (in-memory).
ActionInclusion
| Column | Type | Description |
|---|---|---|
id | binary(16) PK | UUID |
resourceActionId | binary(16) FK → ResourceAction | Which action this inclusion rule applies to |
authorizationStrategyName | varchar(64) | Admin, Read-Only, or Contributor — maps to AuthorizationStrategy |
includeWhenAbove | BIT | Include this action when the Standard Role is granted at a resource type that is an ancestor of the action's resource type |
includeWhenAt | BIT | Include when the grant is exactly at the action's resource type depth |
includeWhenBelow | BIT | Include when the grant is at a resource type deeper than the action's type |
Purpose: This table is the brain of Standard Role expansion. It answers: "If Alice has an Admin Standard Role scoped to a network-group, which actions on which resource types does that automatically include?"
The three strategies:
- Admin:
read= true/true/true (always included).create/delete= false/false/true (only when the grant is at or below the resource).update= false/true/true (at or below). - Read-Only:
read= true/true/true. No write actions ever included. - Contributor: Owner-dynamic strategy — the identity only controls resources they own. Uses the same inclusion rules as Admin but enables owner-identity checking.
Example: Network Group Admin expanding to endpoints
Alice has a Standard Role: Admin at network-group/NG-1. When the auth service builds her AuthorizationSet, it loops over all ResourceActions and for each finds the matching ActionInclusion for strategy Admin. For endpoint.create: includeWhenAbove=false, includeWhenAt=false, includeWhenBelow=true. Alice's grant is at depth 0 (network-group) and endpoint is at depth 2 — so the grant is above the endpoint depth. includeWhenAbove=false → endpoint.create is NOT included. For endpoint.read: includeWhenAbove=true → included. Alice can read all endpoints under NG-1 but cannot create them unless she also has a network-scoped Admin or endpoint-level create grant.
StandardRoleType
| Column | Type | Description |
|---|---|---|
id | binary(16) PK | UUID |
resourceTypeId | binary(16) FK → ResourceType | The resource type this template is for |
authorizationStrategyName | varchar(64) | Admin, Read-Only, or Contributor |
Purpose: A template that says "an Admin (or Read-Only, or Contributor) role for network-group resources". It is the junction between a ResourceType and an AuthorizationStrategy. Without a StandardRoleType row you cannot create a Standard Role of that flavor for that resource type.
Examples: The seeded data includes StandardRoleTypes for:
network-group / Admin,network-group / Read-Onlynetwork / Admin,network / Read-Onlyorganization / Admin,organization / Read-Onlystandard-role / Admin,standard-role / Read-Only(meta — manages the auth system itself)custom-role / Admin,custom-role / Read-Only
Use case: To grant "Alice is an Admin of Network Group NG-1", you find the StandardRoleType where resourceTypeId = network-group.id AND authorizationStrategyName = 'Admin', then create a StandardRole row referencing that type.
Grant Tables
These tables are the runtime permission assignment data. A row in a grant table means "this identity has this permission".
SuperUser
| Column | Type | Description |
|---|---|---|
id | binary(16) PK | UUID of this grant |
identityId | binary(16) | UUID of the identity who gets the god-mode access |
domainId | binary(16) NULLABLE FK → Domain | NULL = full god-mode across all domains (NFADMIN). Non-null = god-mode limited to that domain. |
createdAt / updatedAt / deletedAt | DATETIME(6) | Lifecycle timestamps |
Purpose: Grants every ResourceAction on every resource in the specified domain (or all domains if domainId is null). No path restriction — it truly covers everything. Checked first in the AuthorizationSetBuilder: when building the AuthorizationSet, a SuperUser grant inserts all known ResourceActions with an empty path, bypassing all other grant checks.
NFADMIN use case: Insert a row with identityId = <your NF identity UUID> and domainId = NULL. See the utility SQL:
INSERT INTO SuperUser (id, identityId, domainId)
VALUES (UNHEX(REPLACE(UUID(),'-','')), UNHEX('<your-identity-uuid-no-dashes>'), NULL);
UNIQUE constraint: (identityId, domainId) — one SuperUser grant per (identity, domain) pair. You cannot have two NFADMIN rows for the same identity.
PublicGrant & PublicGrantResourceAncestry
| Column | Type | Description |
|---|---|---|
id | binary(16) PK | UUID |
resourceActionId | binary(16) FK → ResourceAction | The specific action that is publicly allowed |
isAnonymous | BIT | If true, the action is allowed even without a valid JWT (unauthenticated callers). If false, a valid token is required but the action is allowed to all token holders. |
Companion table PublicGrantResourceAncestry stores the optional path scoping. A public grant with no ancestry rows is global (applies to all resource instances). A public grant with ancestry rows is scoped to a specific resource subtree.
Purpose: Grants a specific action to everyone without requiring an explicit per-identity grant. Used for actions that should be universally readable.
Examples seeded by migrations:
geo-region.read— public, anonymous — anyone can read geo-regionsdata-center.read— public, anonymous — anyone can read data centersdomain.read,resource-type.read,resource-action.read— public (authenticated), so any service with a valid token can introspect the auth catalognetwork-version-metadata.read— public, authenticated
In code: When building the AuthorizationSet, public grants are inserted first. Every identity's AuthorizationSet always contains them regardless of what other grants they have.
IdentityResourceAction & IdentityResourceActionResourceAncestry
| Column | Type | Description |
|---|---|---|
id | binary(16) PK | UUID of this grant |
identityId | binary(16) | The identity receiving the direct action grant |
resourceActionId | binary(16) FK → ResourceAction | The specific action being granted |
Companion table IdentityResourceActionResourceAncestry stores the path (which specific resource instances this grant covers).
Purpose: The most surgical permission type. Grants exactly one action on a specific resource (or resource path) to a specific identity, without any role packaging.
Primary use case in the codebase: Granting inter-service permissions. When a service identity (e.g., the Edge Router service) needs to perform a specific action (e.g., service-identity.create scoped to a specific org), a migration inserts an IdentityResourceAction row for that service's identity UUID.
Example: Identity Service can create User Identities under any org
-- The Identity Service's identity UUID is a fixed UUID baked into the DB
INSERT INTO IdentityResourceAction (id, identityId, resourceActionId)
VALUES (UNHEX(REPLACE(UUID(),'-','')),
UNHEX('<identity-service-sid-uuid>'),
(SELECT id FROM ResourceAction RA
JOIN ResourceType RT ON RA.resourceTypeId = RT.id
WHERE RT.code = 'user-identity' AND RA.code = 'create'));
-- No ancestry rows = global scope (all orgs)
This table is also used for one-off, non-role-based grants — e.g., "this specific service can call this specific action on this specific resource" scenarios that do not fit into a StandardRole template.
CustomRole, CustomRoleAction & CustomRoleActionResourceAncestry
CustomRole
| Column | Type | Description |
|---|---|---|
id | binary(16) PK | UUID — for well-known system roles this is a fixed UUID baked into migrations |
name | varchar(256) | Human-readable name, e.g. Read-Only, ALL Auth Domain Resources |
CustomRoleAction
| Column | Type | Description |
|---|---|---|
id | binary(16) PK | UUID |
customRoleId | binary(16) FK → CustomRole | The custom role this action line belongs to |
resourceActionId | binary(16) FK → ResourceAction | The specific action included in this role |
Companion table CustomRoleActionResourceAncestry scopes each CustomRoleAction to a resource path. Different action lines in the same CustomRole can have different path scopes.
IdentityCustomRole
| Column | Type | Description |
|---|---|---|
id | binary(16) PK | UUID |
identityId | binary(16) | The identity granted the role |
customRoleId | binary(16) FK → CustomRole | The role being granted |
UNIQUE: (identityId, customRoleId) — one grant per (identity, role) pair.
Purpose: A custom role is a named, reusable bag of (action, path) tuples. Unlike a Standard Role which auto-expands from ActionInclusion rules, a Custom Role has an explicit, finite list of actions. What you put in is exactly what you get out.
When to use Custom Roles instead of Standard Roles:
- When the permission set does not follow the Admin/Read-Only/Contributor template (e.g., NFSUPPORT Intervention which needs
update-heal,update-resumebut not full Admin) - When a service identity needs a curated subset of actions across multiple domains (e.g., the Auth Read-Only role granting read on all auth catalog resources)
- When the same permission set will be reused across many identities and is unlikely to change (define once, assign many)
Well-known Custom Roles (fixed UUIDs in migrations):
233ae92e-4b5d-4cab-9549-5d55fd1cbf6c— "Read-Only, ALL Auth Domain Resources" — granted to all service identities at startup97315ed6-2fc7-4a40-b7ea-abe53cc6a188— NFSUPPORT Intervention — operational support role with heal/resume/suspend/version-update actions8f32a5aa-…— NFSUPPORT Observation — read-only support role
StandardRole, StandardRoleResourceAncestry & IdentityStandardRole
StandardRole
| Column | Type | Description |
|---|---|---|
id | binary(16) PK | UUID |
standardRoleTypeId | binary(16) FK → StandardRoleType | Which template (Admin/Read-Only/Contributor for which resource type) this instance uses |
createdAt / updatedAt / deletedAt | DATETIME(6) | Lifecycle timestamps; also carries createdBy (the identity UUID that created it) |
StandardRoleResourceAncestry stores the path that scopes this Standard Role to a specific resource subtree (e.g., network-group/NG-1). Every StandardRole row must have at least one ancestry row.
IdentityStandardRole
| Column | Type | Description |
|---|---|---|
id | binary(16) PK | UUID |
identityId | binary(16) | The identity granted the role |
standardRoleId | binary(16) FK → StandardRole | The StandardRole instance being assigned |
UNIQUE: (identityId, standardRoleId).
Purpose: A Standard Role is a scoped instance of a Standard Role Type. "Alice is Admin of Network Group NG-1" is represented as:
- A
StandardRolerow pointing at theStandardRoleTypefor network-group/Admin - A
StandardRoleResourceAncestryrow encoding the pathnetwork-group/NG-1-uuid - An
IdentityStandardRolerow linking Alice's identity UUID to that StandardRole
Key insight: The same StandardRole instance can be assigned to many identities (e.g., "Network Group NG-1 Admin" is a single SR row, but many people can hold it via their IdentityStandardRole rows). Two users with the same role type on the same resource share one SR row.
Deduplication: StandardRoleService.createStandardRole() issues a SELECT FOR UPDATE before inserting — if an SR of the same type scoped to the same resource already exists, it returns the existing one. This prevents duplicates under concurrent requests.
Ancestry (Path) Tables
Every grant that can be scoped to a resource path has a companion ancestry table. The pattern is consistent: one ancestry table per grant type (plus one for custom role actions).
| Ancestry Table | Parent Column | What it scopes |
|---|---|---|
StandardRoleResourceAncestry | standardRoleId | The resource subtree that a Standard Role instance covers |
CustomRoleActionResourceAncestry | customRoleActionId | The resource subtree that a single action line in a Custom Role covers |
IdentityResourceActionResourceAncestry | identityResourceActionId | The resource subtree for a direct action grant |
PublicGrantResourceAncestry | publicGrantId | Optional path scope for a public grant (usually empty = global) |
Ancestry Row Structure
Every ancestry table has the same column set:
| Column | Description |
|---|---|
id | UUID PK |
standardRoleId (or equivalent) | FK to the owning grant row |
childAncestryId | Self-referential FK. Points to the next deeper ancestry row in the path chain. NULL = this is the deepest (leaf) element of the path. |
resourceTypeId | FK → ResourceType — which resource type this path element represents |
resourceId | The actual UUID of the resource instance at this depth |
How a path is encoded: A path network-group/NG-1 → network/NET-2 is two rows:
- Row A:
resourceTypeId = network-group, resourceId = NG-1, childAncestryId = B.id - Row B:
resourceTypeId = network, resourceId = NET-2, childAncestryId = NULL
The leaf row (where childAncestryId IS NULL) is the deepest resource in the path, and the depth of the leaf determines the scope of the grant.
Why the linked-list design? It allows variable-length paths without a fixed schema, and lets the ancestry service traverse from root to leaf or from leaf to root efficiently. The ResourceAncestryService loads all rows at startup and reconstructs the full ResourceIdPath chain in memory.
ResourceAncestryService.storePath() and deletePath(). Direct SQL inserts will corrupt the in-memory cache and cause hard-to-diagnose authorization failures.
Entity Relationship Diagram
The following diagram shows all tables and their foreign key relationships. Ancestry tables (right column) are the path-scoping companions to their parent grant tables.
┌─────────────────────────────────────────────────────────────────────────────────┐ │ METADATA / DEFINITION LAYER │ │ │ │ Domain ────────────────────────────────────────────────────────────────────── │ │ │ id, name, code │ │ │ │ │ ▼ │ │ ResourceType ────────────────────────────────────────────────────────────── │ │ │ id, domainId→Domain, code, parentId→ResourceType(self) │ │ │ │ │ ├──────────────────────┐ ┌────────────────────────── │ │ ▼ ▼ ▼ │ │ ResourceAction StandardRoleType ActionInclusion │ │ id, resourceTypeId id, resourceTypeId id, resourceActionId │ │ code (create/read/ authorizationStrategy authorizationStrategy │ │ update/delete/…) Name (Admin/Read-Only/ Name, includeWhenAbove/ │ │ Contributor) At/Below │ └─────────────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────────────┐ │ GRANT LAYER │ │ │ │ SuperUser │ │ id, identityId, domainId→Domain(nullable) │ │ │ │ PublicGrant ──────────────────────────────── PublicGrantResourceAncestry │ │ id, resourceActionId→RA, isAnonymous id, publicGrantId, childAncestry│ │ Id(self), resourceTypeId, │ │ resourceId │ │ │ │ IdentityResourceAction ───────────────────── IdentityResourceAction │ │ id, identityId, resourceActionId→RA ResourceAncestry │ │ id, identityResourceActionId, │ │ childAncestryId(self), │ │ resourceTypeId, resourceId │ │ │ │ CustomRole │ │ id, name │ │ ▼ │ │ CustomRoleAction ──────────────────────────── CustomRoleAction │ │ id, customRoleId→CR, resourceActionId→RA ResourceAncestry │ │ id, customRoleActionId, │ │ childAncestryId(self), │ │ resourceTypeId, resourceId │ │ ▼ │ │ IdentityCustomRole │ │ id, identityId, customRoleId→CustomRole │ │ UNIQUE(identityId, customRoleId) │ │ │ │ StandardRoleType (from metadata layer above) │ │ ▼ │ │ StandardRole ─────────────────────────────── StandardRoleResourceAncestry │ │ id, standardRoleTypeId→SRT id, standardRoleId, childAnces- │ │ tryId(self), resourceTypeId, │ │ resourceId │ │ ▼ │ │ IdentityStandardRole │ │ id, identityId, standardRoleId→StandardRole │ │ UNIQUE(identityId, standardRoleId) │ └─────────────────────────────────────────────────────────────────────────────────┘
Data Flow Summary
When an identity is granted access the data flows through these tables in sequence:
Admin of network-group
instance row (no path yet)
scoped to NG-1
Alice → SR row
with SR ID + version in claim
expands SR via ActionInclusion
How the Authorization Check Actually Works
The "authorization hook" is the mechanism that connects a JWT in the HTTP Authorization header to a yes/no decision inside every downstream service controller. Understanding this mechanism answers the question: "How does having a row in the database translate to Alice being allowed to call the API?"
The hook operates in three phases:
Phase 1 — Token Parsing (per request, thread-local)
Every incoming HTTP request carries an Authorization: Bearer <NF JWT> header. AuthorizationContext.getContext() is a static method backed by Spring's RequestContextHolder (thread-local). The first call on a request thread parses the NF JWT using JwtConsumer.processToClaims(), validates the signature against the current JWKS, and deserializes the five grant claims (grants-super, grants-public, grants-action, grants-custom, grants-standard) into Map<UUID, Long> — just IDs and version numbers at this point, no DB hit yet.
Phase 2 — Authorization Set Expansion (lazy, first access)
The AuthorizationSet is built lazily on the first call to context.getAuthorizationSet(). The AuthorizationSetBuilder resolves each grant ID (from the JWT) into the full domain object using the in-memory repositories. For each resolved grant it calls the appropriate with*Grant() method which inserts (domain → resourceType → action → grantId → [paths]) entries into an in-memory multi-level map. No DB queries during this phase — everything is in memory. The result is an AuthorizationSet: a queryable index of "what can this identity do, on what paths".
Phase 3 — The Check (at the controller annotation)
Authorization.isAuthorized(identityId, authorizationSet, domain, resourceType, action, targetPath, ownerSupplier) is called. It looks up authorizationSet.getAuthorizedGrantPaths(domain, resourceType, action) then calls grantPaths.subsetIncludes(targetPath) — checking whether any stored grant path is a prefix of the target path. If yes (and owner-dynamic rules pass), the call returns true. This is pure in-memory computation — nanosecond-scale.
Human User Flow (Browser / Console UI)
A human user — Alice — logs in through the Console UI (a browser-based SPA). Here is the complete path from login to authorized API call:
Step 1 — Auth0 login
Alice visits the Console. The SPA redirects to Auth0. Alice enters her email/password (or uses Google SSO). Auth0 validates credentials and issues an Auth0 JWT (OIDC ID token or access token). This token has:
{
"sub": "google-oauth2|116549...", // Auth0 subject (NOT a UUID)
"aud": "https://gateway.staging.netfoundry.io/",
"exp": (now + 86400), // 24-hour lifetime
"https://netfoundry.io/service-identity/id": "alice-uuid-1234" // NF UUID, injected by Auth0 Action
}
Step 2 — Exchange for NF JWT
The Console (or gateway) calls POST /auth/v1/exchange?audience=https://gateway.staging.netfoundry.io/ with the Auth0 JWT in the Authorization header. The Auth Service:
- Validates the Auth0 JWT (signature, expiry, issuer) using the live JWKS from Auth0
- Extracts
alice-uuid-1234from thehttps://netfoundry.io/service-identity/idclaim - Looks up all grants for Alice in the in-memory repositories (IdentityStandardRole, IdentityCustomRole, etc.)
- Builds a
GrantReferenceset for each grant type - Signs a new NF JWT with 5-minute TTL using the current RSA private key (from JwksService)
The NF JWT looks like:
{
"sub": "alice-uuid-1234",
"aud": "https://gateway.staging.netfoundry.io/",
"iss": "NetFoundry",
"exp": (now + 300),
"grants-public": [{"id":"pg-uuid","lm":1},…],
"grants-super": [],
"grants-action": [],
"grants-custom": [],
"grants-standard": [{"id":"sr-uuid","lm":1692000000000}]
}
Step 3 — API call through gateway
Alice's browser sends GET /core/v2/networks/NET-2 to the gateway with Authorization: Bearer <NF JWT>. The gateway passes the request and JWT unchanged to the Core Service.
Step 4 — Authorization hook fires in Core Service
The Core Service controller method for GET /networks/{id} has an annotation like:
@GetMapping("/{id}")
public Network getNetwork(
@AuthRead(on = Network.class) @PathVariable UUID id
) { ... }
Spring's Bean Validation calls AuthIdValidators.AuthReadValidator.isValid(id, context). This calls AuthorizationContext.getContext() (parses the NF JWT from the thread's request), builds the AuthorizationSet (expands SR sr-uuid via ActionInclusion rules), then calls Authorization.isAuthorized("io.netfoundry.network", "network", "read", targetPath, null). If Alice's SR Admin grant on network-group/NG-1 produces a read/network entry in her set, and the target network's path is under network-group/NG-1, the check returns true and the request proceeds. Otherwise a 403 Forbidden is returned immediately.
Step 5 — Token refresh (every 5 minutes)
NF JWTs live for only 5 minutes. Before each API call, the Console checks expiry and re-calls POST /exchange with the still-valid Auth0 JWT (24h lifetime) to get a fresh NF JWT. The new NF JWT always reflects the latest DB state — if Alice's permissions changed in the last 5 minutes, the next NF JWT will contain the updated grant set.
API Account (Machine-to-Human) Flow
An API Account is a non-human identity (an Auth0 Machine-to-Machine application). It is used by automation scripts, CI/CD pipelines, or third-party integrations that call the NetFoundry API programmatically.
Step 1 — Obtain Auth0 token via client_credentials
The automation script calls Auth0 directly using its client credentials (client_id + client_secret). This is the OAuth2 client_credentials grant — no user interaction required.
POST https://netfoundry-sandbox.auth0.com/oauth/token
Content-Type: application/json
{
"grant_type": "client_credentials",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"audience": "https://gateway.staging.netfoundry.io/"
}
→ { "access_token": "<Auth0 JWT>", "expires_in": 86400 }
Step 2 — Exchange for NF JWT (identical to human user flow)
Same POST /auth/v1/exchange call. The Auth0 JWT for an API Account contains the same https://netfoundry.io/service-identity/id claim (set by an Auth0 Action) with the API Account's NF identity UUID. The auth service looks up that identity's grants and issues an NF JWT.
Step 3 — Use the NF JWT exactly like a human user
All subsequent API calls use Authorization: Bearer <NF JWT>. The Core/Identity services have no idea whether the token came from a human or an API account — they both go through the same @AuthRead/@AuthCreate hook.
Granting an API Account access
The API Account is represented in the DB as an api-account-identity resource type under an organization. Granting it access means assigning it Standard Roles or Custom Roles just like a human user. The identity UUID comes from the Identity Service when the API Account is created.
-- Example: Grant API Account a Read-Only role on a network group
POST /auth/v1/standard-roles
{
"standardRoleTypeId": "<SRT UUID for network-group/Read-Only>",
"resourceIdPath": [
{ "resourceTypeId": "<network-group type UUID>", "resourceId": "NG-1-uuid" }
]
}
→ { "id": "sr-new-uuid" }
POST /auth/v1/standard-role-grants
{
"identityId": "api-account-identity-uuid",
"standardRoleId": "sr-new-uuid"
}
api-account-identity) is a customer-controlled identity — they create it in the Console and use it for their automation. A Service Identity (service-identity) is a NetFoundry-internal identity for the platform's own services (Core Service, Identity Service, etc.). Both go through the same authorization model, but Service Identities typically get their permissions via IdentityResourceAction grants or well-known Custom Roles inserted by Flyway migrations, not via the API.
Service-to-Service (Internal Platform) Flow
Internal NetFoundry services (Core Service, Identity Service, Edge Router Service, etc.) also call each other's APIs. They use a different pattern: the InterServiceTokenProvider.
Step 1 — Service obtains an Auth0 token at startup
Every internal service has its own Auth0 Machine-to-Machine application (client_id + client_secret). On startup, InterServiceTokenProvider calls POST {auth0Issuer}/oauth/token with grant_type=client_credentials and audience=<target-service-URL>. The audience is the target service's URL, not the gateway URL — because inter-service calls are direct (not through the gateway).
Step 2 — Exchange for NF JWT
The InterServiceTokenProvider calls POST /exchange?audience=<target-service-URL>. In the auth service itself, this does not go over HTTP — it calls AuthorizationLogic.requestJwt() directly in-process (configured in ServerConfiguration). For all other services it is a real HTTP call.
The resulting NF JWT has:
{
"sub": "core-service-identity-uuid",
"aud": "https://identity.staging.netfoundry.io/", // the TARGET service
"iss": "NetFoundry",
"exp": (now + 300)
}
Step 3 — Cached per target audience
InterServiceTokenProvider maintains a LoadingCache<String targetAudience, String nfJwt>. When the Core Service needs to call the Identity Service, it calls tokenProvider.getToken("https://identity.staging.netfoundry.io/") — the cache returns the cached NF JWT, or loads a new one if absent/expired. A background ScheduledTokenRefresh proactively refreshes tokens 60 minutes before they expire.
Step 4 — Authorization hook fires in the target service
Same mechanism as the user flow. The target service (e.g., Identity Service) receives the request with Authorization: Bearer <service NF JWT>. The @AuthCreate(on=UserIdentity.class) annotation on the controller method fires, which calls AuthorizationContext.getContext(), builds the AuthorizationSet, and checks whether the Core Service's identity UUID has user-identity.create permission on the org's path. The permission was granted via an IdentityResourceAction row inserted by a Flyway migration.
How a new service gets its permissions
When you onboard a new internal service to the platform, you need to:
- Create a Service Identity row in the Identity Service DB for the service (gets a fixed UUID)
- Create an Auth0 M2M application for the service
- Add an Auth0 Action that injects the Service Identity UUID into the token as
https://netfoundry.io/service-identity/id - Write a Flyway migration in
authorization-devthat grants the service the exact actions it needs:
- Use
IdentityResourceActionrows for fine-grained, single-action grants - Or use
IdentityCustomRolerows pointing at a well-known Custom Role (like the Auth Read-Only role)
- Use
- The migration runs on next deployment — the service can now call the target APIs
IdentityResourceAction (most specific) or IdentityCustomRole (curated set) to follow the principle of least privilege.
The @Auth Annotations — How Downstream Services Enforce Authorization
The client jar (authorization-*-client.jar) ships a set of Jakarta Bean Validation annotations that downstream services add to their Spring MVC controller methods. These annotations are the hook — they are where the yes/no decision happens.
Available Annotations
| Annotation | Action Code | What it checks |
|---|---|---|
@AuthRead | read | Caller can read the resource identified by the annotated ID |
@AuthCreate | create | Caller can create a resource of the given type under the parent resource identified by the annotated ID |
@AuthUpdate | update | Caller can update the resource identified by the annotated ID |
@AuthDelete | delete | Caller can delete the resource identified by the annotated ID |
@AuthAction | custom (e.g. update-heal) | Caller has the specified custom action on the resource |
How They Work
Each annotation is a @Constraint backed by multiple ConstraintValidator implementations (one for each annotated value type: UUID, Supplier<UUID>, WhereClause, Predicate). When Spring MVC processes the controller method parameters, Jakarta Validation is invoked — which triggers the validator.
The validator calls AuthorizationContext.getContext() to obtain the thread-local context, then calls context.isAuthorized(...). If the check fails, the validator returns false from isValid(), which causes Spring to throw a ConstraintViolationException, which is caught by AuthExceptionAdvice and translated to a 403 Forbidden.
Usage Examples
Read a network by ID
@GetMapping("/networks/{id}")
public Network getNetwork(
@AuthRead(on = Network.class) @PathVariable UUID id
) {
return networkService.findById(id);
}
The on = Network.class tells the validator which resource type to check. The validator resolves the ResourceType code from the class (via @AuthResourceType annotation on the domain class), builds the target path as network-group/<ng> → network/<id> (via StandardResourceResolver), and calls isAuthorized("io.netfoundry.network", "network", "read", targetPath).
Create an endpoint under a network
@PostMapping("/networks/{networkId}/endpoints")
public Endpoint createEndpoint(
@AuthCreate(on = Endpoint.class) @PathVariable UUID networkId,
@RequestBody EndpointRequest request
) {
return endpointService.create(networkId, request);
}
@AuthCreate means "the annotated value (networkId) is the parent resource, and the check is whether the caller can create an Endpoint under it". The target path is built as network-group/<ng> → network/<networkId> and the action is endpoint.create. This requires the caller to have a grant that includes endpoint.create on a path that covers this network.
Custom action (e.g. heal an edge router)
@PostMapping("/edge-routers/{id}/heal")
public void healEdgeRouter(
@AuthAction(on = EdgeRouter.class, action = "update-heal") @PathVariable UUID id
) {
edgeRouterService.heal(id);
}
This requires the caller to have the custom action update-heal on that specific edge router. Only the NFSUPPORT Intervention Custom Role contains this action — so only identities granted that role can call this endpoint.
The AuthorizationContext + @Auth Annotation Flow — Complete Picture
HTTP Request with Authorization: Bearer <NF JWT>
│
▼
┌─────────────────────────────────────────────────────────┐
│ HandlerInterceptor (preHandle) │
│ → checkVersions() on all InMemoryRepositories │
│ → if DB changed, invalidate in-memory cache │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Spring MVC DispatcherServlet │
│ → routes to controller method │
│ → Jakarta Bean Validation triggered on @Auth* params │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ AuthIdValidators.AuthReadValidator.isValid(UUID id, …) │
│ → AuthorizationContext.getContext() │
│ → reads Authorization header (once per request) │
│ → JwtConsumer.processToClaims() │
│ → deserializes 5 grant claims → Map<UUID,Long> │
│ → context.isAuthorized(domain, resourceType, action, │
│ targetPath, ownerSupplier) │
│ → if authorizationSet is null: buildAuthorizationSet()│
│ → for each grant ID in token claims: │
│ → resolve from in-memory repository │
│ → AuthorizationSetBuilder.with*Grant() │
│ → inserts (domain→type→action→grantId→paths) │
│ → Authorization.isAuthorized(...) │
│ → getAuthorizingGrantPaths() │
│ → grantPaths.subsetIncludes(targetPath) │
│ → check owner-dynamic if needed │
└─────────────────────────────────────────────────────────┘
│
┌────┴────┐
true false
│ │
▼ ▼
proceed ConstraintViolationException
→ AuthExceptionAdvice
→ 403 Forbidden
- A DB row (
IdentityStandardRole,IdentityCustomRole, etc.) is written by a REST API call or Flyway migration - On the next NF token request (
POST /exchange), the Auth Service reads that row and includes the grant ID + version in the NF JWT claims - The NF JWT is presented to a downstream service's controller — the @Auth annotation fires
- The
AuthorizationSetis built in-memory by resolving the grant ID against the local in-memory cache (itself synced from DB at startup + version checks) Authorization.isAuthorized()does a pure in-memory prefix path match to return true or false- true → the API call proceeds. false → 403 Forbidden
Customer Onboarding Setup
Giving a new customer (tenant) their first admin user requires two distinct layers of configuration. Layer 1 is platform-wide and is done once via Flyway migrations. Layer 2 is per-customer and is driven via REST API calls (typically by the provisioning service).
Layer 1 — Platform-Wide Prerequisites (Flyway Migrations)
These objects exist before any customer is created. They define the permission grammar.
Domains
Three domains partition the resource namespace: io.netfoundry.identity (orgs, users),
io.netfoundry.network (networks, endpoints, etc.), and io.netfoundry.auth (roles,
grants). Every ResourceType and every StandardRoleType belongs to exactly one domain.
ResourceTypes & the Resource Tree
A parent-child tree of resource types defines the hierarchy that paths follow.
Example for the identity domain: organization (depth 0).
Example for the network domain: network-group (depth 0) → network (depth 1) → endpoint (depth 2).
Depth determines how many path elements a StandardRole's path must contain.
ResourceActions
Named CRUD verbs (create-organization, read-organization, etc.) are registered per resource type.
These are the atoms that grants either include or exclude.
ActionInclusion Rules
Each ActionInclusion row ties a ResourceAction to an authorizationStrategyName
(Admin / Read-Only / Contributor) plus three boolean flags:
includeWhenAbove, includeWhenAt, includeWhenBelow.
These flags say whether the action is granted when the caller's grant path is above, at, or below the target resource in the tree.
Admin covers every action. Read-Only covers read-* actions only.
StandardRoleTypes (Business Role Templates)
A StandardRoleType row pairs a name (Admin, Read-Only) with a target
ResourceType and an authorizationStrategyName.
Examples created at platform boot time:
| Name | ResourceType | Strategy |
|---|---|---|
| Organization Admin | organization | Admin |
| Organization Read-Only | organization | Read-Only |
| Network-Group Admin | network-group | Admin |
| Network-Group Read-Only | network-group | Read-Only |
| Network Admin | network | Admin |
| Network Read-Only | network | Read-Only |
Auth StandardRoleTypes (Self-Service Delegation Templates)
Separate StandardRoleTypes in the io.netfoundry.auth domain let one identity delegate
role management to another. The key ones are:
standard-role / Admin— can create, delete, and grant StandardRolesstandard-role / Read-Only— can read StandardRolescustom-role / Admin— can manage CustomRoles
Layer 2 — Per-Customer Setup Steps (REST API)
Each of these steps is triggered for every new customer. The provisioning service calls the Auth Service API.
Create Org-Level StandardRoles
POST /standard-roles with standardRoleTypeId = Organization/Admin (or Read-Only)
and path = the new org's resource id path.
This writes one StandardRole row and one StandardRoleResourceAncestry row per role variant.
POST /standard-roles
{
"standardRoleTypeId": "<org-admin-srt-id>",
"path": "nf.id:org/<acme-org-uuid>"
}
Create Network-Group-Level StandardRoles
Same pattern as above but with standardRoleTypeId = Network-Group/Admin or Read-Only
and a 2-element path: nf.id:org/<org-uuid>:ng/<ng-uuid>.
Repeat for every NG created under the org.
Create Auth Standard Roles (Delegation Wrappers)
For each StandardRole created above, create two Auth Standard Roles in the
io.netfoundry.auth domain — one for Admin-level delegation and one for Read-Only.
The path for an Auth SR is just the StandardRole's own UUID:
nf.auth:standard-role/<sr-uuid>.
This is what enables self-service: an identity that holds the Auth-SR-Admin grant over a given StandardRole can grant that StandardRole to others.
Grant Alice the Org Admin StandardRole
POST /standard-role-grants links Alice's identity UUID to the Org/Admin StandardRole.
This writes one IdentityStandardRole row. On Alice's next POST /exchange,
this grant appears in her NF JWT's grants-standard claim.
POST /standard-role-grants
{
"standardRoleId": "<acme-org-admin-sr-id>",
"identityId": "<alice-identity-uuid>"
}
Grant Alice the Auth Standard Role (Optional, for Self-Service)
If Alice should be able to grant the Org/Admin role to other users herself,
grant her the Auth-SR-Admin wrapper. This is a second POST /standard-role-grants
pointing to the Auth StandardRole that wraps the Org/Admin SR.
- 2 rows in
StandardRole(org Admin + org Read-Only) + ancestry rows - 2 rows × N network-groups in
StandardRole(ng Admin + ng Read-Only) + ancestry rows - 2 Auth SR rows per Standard Role in step 1-2 + ancestry rows
- 1 or more rows in
IdentityStandardRoleassigning Alice to one of the above roles
Alice's Complete DB Data Flow
This walkthrough shows exactly which rows are written to which tables when Alice is onboarded as the Organization Admin of Acme Corp, which owns the Dev Network Group. All UUIDs are shortened for readability; real rows use full v4 UUIDs.
- Alice identity UUID:
alice-0001 - Acme org UUID:
acme-0001 - Dev network-group UUID:
devng-001 - Provisioner service identity (creates the roles):
prov-0001
Phase 0 — Platform Tables (Already Exist)
These rows were inserted by Flyway migrations, not per-customer API calls.
domain
| id | code |
|---|---|
dom-id-01 | io.netfoundry.identity |
dom-net-01 | io.netfoundry.network |
dom-auth-01 | io.netfoundry.auth |
resource_type
| id | code | domain_id | parent_id | depth |
|---|---|---|---|---|
rt-org | organization | dom-id-01 | null | 0 |
rt-ng | network-group | dom-net-01 | null | 0 |
rt-net | network | dom-net-01 | rt-ng | 1 |
rt-sr | standard-role | dom-auth-01 | null | 0 |
standard_role_type
| id | name | resource_type_id | authorization_strategy_name |
|---|---|---|---|
srt-oa | Organization Admin | rt-org | Admin |
srt-oro | Organization Read-Only | rt-org | Read-Only |
srt-nga | Network-Group Admin | rt-ng | Admin |
srt-ngro | Network-Group Read-Only | rt-ng | Read-Only |
srt-sra | Standard-Role Admin | rt-sr | Admin |
srt-srro | Standard-Role Read-Only | rt-sr | Read-Only |
Phase 1 — Create Org-Level StandardRoles for Acme
Provisioner calls POST /standard-roles twice — once for Admin, once for Read-Only.
standard_role (new rows)
| id | standard_role_type_id | created_by |
|---|---|---|
sr-oa-acme | srt-oa | prov-0001 |
sr-oro-acme | srt-oro | prov-0001 |
standard_role_resource_ancestry (new rows)
The Org-Admin SR's path is a single element: [rt-org / acme-0001].
The ancestry service inserts from leaf (index 1) to root (index 0), but a single-element path has
only one row with child_ancestry_id = NULL.
| id | standard_role_id | resource_type_id | resource_id | child_ancestry_id |
|---|---|---|---|---|
anc-oa-1 | sr-oa-acme | rt-org | acme-0001 | null |
anc-oro-1 | sr-oro-acme | rt-org | acme-0001 | null |
At startup the ResourceAncestryService.getAll() call reads these rows and reconstructs
the path. The StandardRole bean then carries:
sr-oa-acme.path = [organization/acme-0001].
Phase 2 — Create Network-Group-Level StandardRoles for "Dev NG"
Same pattern, but the path has two elements: [rt-ng / devng-001] (NG depth=0, so path is 1 element).
standard_role (new rows)
| id | standard_role_type_id | created_by |
|---|---|---|
sr-nga-dev | srt-nga | prov-0001 |
sr-ngro-dev | srt-ngro | prov-0001 |
standard_role_resource_ancestry (new rows)
| id | standard_role_id | resource_type_id | resource_id | child_ancestry_id |
|---|---|---|---|---|
anc-nga-1 | sr-nga-dev | rt-ng | devng-001 | null |
anc-ngro-1 | sr-ngro-dev | rt-ng | devng-001 | null |
Phase 3 — Create Auth Standard Roles (Delegation Wrappers)
For each SR created in phases 1-2, we create two Auth SRs (Admin + Read-Only) whose path is just
[standard-role / <wrapped-sr-id>].
Only the Admin wrapper for sr-oa-acme is shown here for brevity.
standard_role (new rows)
| id | standard_role_type_id | notes |
|---|---|---|
auth-sr-1 | srt-sra | Auth Admin wrapper for sr-oa-acme |
auth-sr-2 | srt-srro | Auth Read-Only wrapper for sr-oa-acme |
auth-sr-3 | srt-sra | Auth Admin wrapper for sr-oro-acme |
| … | … | one pair per SR from phases 1-2 |
standard_role_resource_ancestry (new rows)
| id | standard_role_id | resource_type_id | resource_id | child_ancestry_id |
|---|---|---|---|---|
anc-auth-1 | auth-sr-1 | rt-sr | sr-oa-acme | null |
anc-auth-2 | auth-sr-2 | rt-sr | sr-oa-acme | null |
| … | … | … | … | null |
io.netfoundry.auth domain, the same grant machinery that
protects networks and endpoints can also protect role management operations.
Phase 4 — Grant Alice the Org Admin Role
Provisioner calls POST /standard-role-grants.
identity_standard_role (new row)
| id | identity_id | standard_role_id | created_by | deleted_at |
|---|---|---|---|---|
isr-alice-1 | alice-0001 | sr-oa-acme | prov-0001 | null |
This single row is the activation event. Everything before this was structural scaffolding; this row is what gives Alice her permissions.
Phase 5 — Alice Logs In: JWT Construction
Alice calls POST /exchange with her Auth0 token. The auth service runs addDefaultClaims():
Scan IdentityStandardRole table
Finds isr-alice-1 where identity_id = alice-0001 and deleted_at IS NULL.
Build GrantReference
Creates { id: "isr-alice-1", lastModified: <updatedAt timestamp> }.
Embed in JWT claim
The reference is serialized into the grants-standard JWT claim. The full NF JWT looks like:
{
"sub": "alice-0001",
"grants-standard": [{ "id": "isr-alice-1", "lastModified": 1716400000000 }],
"grants-public": [ /* platform-wide PublicGrants */ ],
"grants-action": [],
"grants-custom": [],
"grants-super": [],
"exp": 1716400300
}
Phase 6 — Alice Accesses a Resource: Authorization Check
Alice sends a GET /networks/net-xyz request to the Network Service.
The @AuthRead(on = Network.class) annotation fires.
Parse JWT
AuthorizationContext.getContext() reads the Authorization header and
deserializes grants-standard into Map<UUID, Long> = { "isr-alice-1": 1716400000000 }.
Resolve Grant
AuthorizationSetBuilder looks up isr-alice-1 in the local in-memory cache
(pre-loaded from DB at startup, version-checked). Retrieves the StandardRoleGrant bean,
then the StandardRole bean (sr-oa-acme), then the StandardRoleType
(srt-oa — Organization Admin, strategy=Admin).
Expand via ActionInclusion
getIncludedActions(srt-oa) queries the ActionInclusion rows for strategy=Admin
and returns all read/write/create/delete actions for every resource type under organization
(including read-network). Each action is inserted into the AuthorizationSet
with path [organization/acme-0001].
isAuthorized() check
The target path for net-xyz is [network-group/devng-001, network/net-xyz].
The grant path is [organization/acme-0001]. The authorization check then does
grantPath.includes(targetPath) — but wait, org is in the identity domain while
network is in the network domain. Organization Admin grants cover all network resources
because the ActionInclusion rule for Admin sets includeWhenAbove=true for all actions
with no depth restriction. The grant path's type depth vs the target path's type depth determines
which include-when flag applies.
identity_standard_role row cascades — through StandardRoleType → ActionInclusion rules
→ AuthorizationSet expansion — into permission to perform every action on every resource under Acme Corp.
Why Each Step Exists — Q&A
Each onboarding step exists for a specific reason. This Q&A walks through the design rationale.
Q: Why define Domains at all? Why not just have one flat namespace?
Domains let the auth model partition the world into independent business areas — identity, networking, auth
management — each with its own resource type tree. A grant in the io.netfoundry.network domain
cannot accidentally cross into the io.netfoundry.auth domain. This prevents a network admin from
being able to modify roles.
Q: Why have ResourceTypes AND a tree structure? Why not just a flat list?
The tree encodes ownership: a network exists inside a network-group which exists inside an organization. The tree is what allows an Org Admin grant to cascade down to networks and endpoints automatically, without storing one explicit grant per resource. A flat list would require O(N) grants where N is the number of resources; the tree collapses that to O(1) via prefix matching.
Q: Why store ActionInclusion rules in the DB instead of hardcoding them in Java?
New resource types are added regularly (endpoints, app-wans, posture-checks, etc.). Hardcoding the mapping would require a code deploy every time a new resource type or a new Admin/Read-Only coverage rule is added. Storing them in the DB (via Flyway migrations) means adding resource coverage is a SQL migration — no code change, no deploy of the auth service.
Q: Why have StandardRoleTypes? Why not just grant permissions directly to identities?
Direct per-identity grants don't scale. With thousands of identities and dozens of resource types, every onboarding would require writing dozens of explicit action grants. StandardRoleTypes are reusable templates: define "Org Admin" once and stamp it out for each new organization. Adding a new action to "Org Admin" (via an ActionInclusion row) automatically applies to every identity that holds any org-level Admin StandardRole.
Q: Why create a StandardRole instance per customer? Why not share one StandardRole across customers?
A StandardRole carries a specific path — the exact resource it protects (e.g., Acme Corp's org UUID). Sharing a StandardRole across customers would mean its path would need to cover multiple orgs simultaneously, which is not how the prefix matching works. Each customer gets their own StandardRole instance so the path constraint is correctly scoped to that customer's resources only.
Q: Why do we need both "Org Admin SR" AND "Network-Group Admin SR"? Doesn't Org Admin cover everything?
The Org Admin SR does grant full access to everything under the org — including networks. But NF also supports sub-org administrators who manage only a single network group, not the whole org. Creating a Network-Group Admin SR for each NG allows a future identity to be granted NG-level admin without org-wide admin. It is also required to create Auth SRs at the NG level so that NG admins can self-service grant NG roles to others.
Q: Why create Auth Standard Roles? What's wrong with only having platform admins grant roles?
Without Auth SRs, only a SuperUser (or whoever has a direct action grant on StandardRoles) can assign roles. Auth SRs enable delegated administration: an Org Admin can be given the Auth-SR-Admin grant for their org's StandardRoles, and they can then onboard their own users without any involvement from the platform team. This is the self-service model that allows NetFoundry to scale across thousands of customers.
Q: Why is the Auth SR's path just the StandardRole's UUID?
The "resource" that an Auth SR is protecting is the StandardRole itself. The standard-role
resource type in the io.netfoundry.auth domain exists precisely so that StandardRoles can be
first-class protected resources. The path [standard-role / sr-oa-acme] means "I have admin rights
over the StandardRole object sr-oa-acme".
Q: Why use a linked-list table (ResourceAncestry) instead of storing the path as a JSON column?
A linked-list allows path elements to be queried individually and joined efficiently against the
resource_type table for type resolution. It also allows partial path matching queries (e.g., "find
all StandardRoles whose path starts with org X") to be expressed as SQL joins rather than parsed string
operations. JSON blobs would require parsing in application code and would be opaque to the DB query planner.
Q: Why does the ancestry service insert from leaf to root instead of root to leaf?
The linked list is stored as a singly-linked chain where each node points to its child (the next step
deeper in the path). Inserting leaf first means the child row's ID is available when inserting the parent row
(which needs to store child_ancestry_id). Inserting root first would require a second update pass
to fill in the child pointer.
Q: Why only embed a GrantReference (id + version) in the JWT instead of the full grant data?
An identity may have hundreds of grants. Embedding the full grant data for each would produce a JWT that is
megabytes in size, too large for HTTP headers. The compact reference approach keeps the JWT small (typically
under 8 KB) while the full data is resolved in-memory at check time from the local cache. The lastModified
version field lets the auth service detect stale cache entries and refresh them before an authorization decision.
Q: Why is the in-memory cache loaded at startup rather than queried per-request from MySQL?
Authorization checks happen on every API call to every downstream service. A MySQL query per check would mean hundreds of network round-trips per second under moderate load. The in-memory model trades memory for latency: the full grant table fits comfortably in RAM and checks become nanosecond-speed hash lookups. Consistency is maintained by the version numbers in the JWT claims — if a grant's version in the JWT doesn't match what's in the cache, the cache is refreshed from DB before the decision is made.
Q: Why is computeIncludedAuthorizations false in the client jar and true only in the auth service?
"Included authorizations" refers to the standard role inclusion calculation — figuring out which standard roles
are subsets of others. This is an expensive O(n²) operation over all actions. Downstream services don't need it
(they just need to know if a specific action is permitted). Only the auth service itself needs it — specifically
for the GET /authorizations debugging endpoint that shows the full expanded authorization set.
Keeping it off by default avoids paying that cost on every token validation across every service.
Q: Why is the NF JWT only valid for 300 seconds?
Short TTLs limit the blast radius of a revoked grant. If Alice's access is revoked (her
identity_standard_role row is soft-deleted), the revocation takes effect at most 300 seconds later
— the next time her JWT expires and she must call POST /exchange again. A longer TTL would mean
revoked access continues to work for that duration. 300 seconds is the trade-off between security (short-lived)
and performance (fewer exchange calls).
End-to-End: DB Row → NF JWT → Authorization Decision
This section answers the question: "How does a row in IdentityStandardRole translate to Core Management Service (or any MOP service) saying YES or NO to an API call?"
There are two distinct phases separated by the POST /exchange boundary:
Phase A — Auth Service (token issuance)
Runs inside authorization-dev. Reads the DB, builds compact grant references, signs the NF JWT. Happens once per 5-minute token window.
Phase B — Downstream Services (token consumption)
Runs inside Core Service, Customer Mgmt, etc. Parses the NF JWT, expands grant references from local in-memory cache, evaluates path prefix match. Happens on every API call.
Phase A — Inside the Auth Service at POST /exchange
POST /exchange (caller presents Auth0 JWT)
│
▼
AuthorizationLogic.requestJwt()
1. Validate Auth0 JWT → extract identityId from custom claim
2. Load IdentityStandardRole rows for identityId → [{ standardRoleId, updatedAt }]
3. Load IdentityCustomRole rows for identityId → [{ customRoleId, updatedAt }]
4. Load IdentityResourceAction rows for identityId → [{ actionGrantId, updatedAt }]
5. Load SuperUser rows for identityId → [{ superUserId, updatedAt }]
6. Load PublicGrant rows (global, same for all) → [{ pgId, updatedAt }]
│
▼
Build JwtClaims:
"grants-standard": "[{id: sr-uuid, lm: 1692000000000}, ...]"
"grants-custom": "[{id: cr-uuid, lm: ...}, ...]"
"grants-action": "[{id: ira-uuid, lm: ...}, ...]"
"grants-super": "[{id: su-uuid, lm: ...}, ...]" ← empty if not superuser
"grants-public": "[{id: pg-uuid, lm: ...}, ...]"
│
▼
Sign with RSA private key (from Redis JwksService)
Return NF JWT (exp = now + 300 seconds)
Phase B — Inside Every Downstream Service (Core Mgmt, Customer Mgmt, etc.)
Incoming HTTP request
Authorization: Bearer <NF JWT>
│
▼
① AuthorizationContext.getContext() [thread-local, per-request]
• Parses NF JWT using Jose4j JwtConsumer
• Validates RSA signature against /auth/jwks (cached public key)
• Deserializes 5 grant claims into Map<UUID grantId, Long version>
• No DB queries yet — just parse + validate
│
▼ (lazy, on first access)
② AuthorizationSetBuilder.build() [in-memory only]
For each grantId in grants-standard:
→ Look up StandardRole in InMemoryMarkDeleteBeanRepository
→ Verify version matches (if stale, refresh from MySQL)
→ Follow standardRoleTypeId → StandardRoleType { resourceType, strategy }
→ Load ancestry rows → reconstruct ResourceIdPath
→ For each ResourceAction in the system:
Find ActionInclusion rows where strategy matches
Compute depth delta (grant depth vs action resource depth)
Apply includeWhenAbove / includeWhenAt / includeWhenBelow
If included: insert into AuthorizationSet:
authSet[domain][resourceType][action][grantId].add(path)
For each grantId in grants-custom:
→ Resolve CustomRole → CustomRoleAction rows
→ Each action has its own path from CustomRoleActionResourceAncestry
→ Insert directly into AuthorizationSet (no ActionInclusion expansion)
For each grantId in grants-action:
→ Resolve IdentityResourceAction → single (action, path) pair
→ Insert into AuthorizationSet
For each grantId in grants-super:
→ Resolve SuperUser → all ResourceActions in domain (or all domains)
→ Insert ALL actions with empty path (covers everything)
grants-public already known → same expansion for all identities
│
▼
AuthorizationSet (result):
{
"io.netfoundry.network": {
"network": {
"read": { sr-uuid → [ ResourceIdPath[network-group/NG-1] ] },
"update": { sr-uuid → [ ResourceIdPath[network-group/NG-1] ] }
},
"endpoint": {
"read": { sr-uuid → [ ResourceIdPath[network-group/NG-1] ] },
"create": { sr-uuid → [ ResourceIdPath[network-group/NG-1] ] }
}
}
}
│
▼
③ Controller method fires (e.g., GET /networks/{id})
@AuthRead(on = Network.class) @PathVariable UUID networkId
→ ConstraintValidator calls context.isAuthorized(
domain="io.netfoundry.network",
resourceType="network",
action="read",
targetPath=ResourceIdPath[ network-group/NG-1 → network/NET-2 ]
)
│
▼
④ Authorization.isAuthorized() [pure in-memory, nanoseconds]
grantPaths = authSet["io.netfoundry.network"]["network"]["read"]
= { sr-uuid → [network-group/NG-1] }
For each grantPath in grantPaths.values():
if grantPath.includes(targetPath)?
→ ResourceIdPath[network-group/NG-1].includes(
ResourceIdPath[network-group/NG-1 → network/NET-2])
→ YES — grant path is a PREFIX of target path
→ return true → ✅ AUTHORIZED
OR if only owner-dynamic paths exist:
→ check ownerSupplier.get() == identityId → AUTHORIZED/DENIED
│
▼
true → request proceeds to service method
false → ConstraintViolationException → AuthExceptionAdvice → 403 Forbidden
Concrete Example: Alice reads a network in Core Management
Setup (DB state)
Alice (alice-uuid) has been granted Network Admin on network-group NG-1. The DB contains:
StandardRoleType:{ resourceTypeId=network-group, strategy=Admin }StandardRole:{ id=sr-ng1-admin, standardRoleTypeId=... }StandardRoleResourceAncestry: one row —{ resourceType=network-group, resourceId=NG-1-uuid, childAncestryId=null }IdentityStandardRole:{ identityId=alice-uuid, standardRoleId=sr-ng1-admin }
Phase A — Alice calls POST /exchange
Auth Service sees IdentityStandardRole row → emits NF JWT:
{
"sub": "alice-uuid",
"grants-standard": "[{\"id\":\"sr-ng1-admin\",\"lm\":1692000000000}]",
"grants-public": "[{\"id\":\"pg-geo-read\",\"lm\":100}, ...]",
"grants-super": "[]",
"grants-action": "[]",
"grants-custom": "[]",
"exp": <now + 300>
}
Phase B — Alice calls GET /core/v2/networks/NET-2
Core Service receives: Authorization: Bearer <NF JWT>
Step 1 — Parse JWT → grants-standard = [{id: sr-ng1-admin, lm: 1692000000000}]
Step 2 — Expand sr-ng1-admin:
StandardRoleType: Admin on network-group
Path: network-group/NG-1-uuid
ActionInclusion for Admin + network.read → includeWhenAbove=true
(Alice is granted at depth 0 = network-group; network is depth 1 → grant is ABOVE)
→ authSet["io.netfoundry.network"]["network"]["read"][sr-ng1-admin] = {network-group/NG-1}
Step 3 — @AuthRead(on=Network.class) fires:
targetPath = network-group/NG-1-uuid → network/NET-2-uuid
Step 4 — isAuthorized check:
grantPath: network-group/NG-1-uuid
targetPath: network-group/NG-1-uuid → network/NET-2-uuid
grantPath.includes(targetPath)? YES (grant is a prefix)
→ ✅ AUTHORIZED — request proceeds
What if Alice tries to access network NET-99 (under NG-2, a different network group)?
targetPath = network-group/NG-2-uuid → network/NET-99-uuid grantPath: network-group/NG-1-uuid grantPath.includes(targetPath)? → network-group/NG-1 ≠ network-group/NG-2 → paths diverge at element 0 → NO match → ❌ DENIED — 403 Forbidden
Summary: What Each Layer Contributes to the Authorization Decision
| DB Layer | Used in Phase | Contributes |
|---|---|---|
| Layer 1: Domain, ResourceType, ResourceAction, ActionInclusion | Phase B (expansion) | Defines which concrete actions a strategy grants at each depth level |
| Layer 2: StandardRoleType, CustomRole, CustomRoleAction | Phase B (expansion) | The role template — what actions the role contains |
| Layer 3: IdentityStandardRole, IdentityCustomRole, etc. | Phase A (JWT issuance) + Phase B (resolution) | The identity → grant binding. Phase A reads it to build JWT claims; Phase B resolves it to expand permissions |
| Layer 4: *ResourceAncestry tables | Phase B (expansion) | The path scope — which specific resource subtree the grant covers. Used by grantPath.includes(targetPath) |
| NF JWT claims (grants-standard etc.) | Phase B (parse) | The transport: carries Layer 3 IDs + versions. The lm version detects stale cache entries |
| In-memory repositories (InMemoryMarkDeleteBeanRepository) | Phase B (resolution) | The cache: avoids MySQL round-trips on every check. Loaded at startup, version-invalidated by JWT lm |
- The JWT carries only IDs, not permissions. All permission expansion happens in the downstream service using its local in-memory copy of the auth DB.
- Authorization is a path prefix check. If any stored grant path is a prefix of the target resource path, the identity is authorized — regardless of how deeply the resource is nested below the grant.
How to Master This Auth Service
This is a structured path to go from "I read the docs" to being the person other teams come to when something breaks or when a new service needs to be onboarded.
GET /auth/v1/authorizations?identityId=<uuid> endpoint — 30 minutes with it teaches more than 2 hours reading code.
| Stage | Time | You can do |
|---|---|---|
| 1 — Mental Model | Week 1 | Explain auth to anyone, trace a grant from DB to JWT |
| 2 — Read the Code | Week 2 | Follow the request lifecycle, debug most 403s |
| 3 — Make Changes | Week 3–4 | Add resource types, write migrations, write tests |
| 4 — Hard Problems | Month 2 | Own edge cases: owner-identity, key rotation, cache consistency |
| 5 — Go-To Person | Ongoing | Answer 403s from other teams, review all auth migration PRs |
Stage 1 — Understand the Mental Model (Week 1)
You can't master code you can't picture. Get these three things rock solid before touching the codebase.
Exercise 1 — Draw the path prefix check by hand
Take any real grant from the DB and manually trace:
- What
ResourceIdPathdoes the grant produce? - What
ResourceIdPathdoes a real API call produce? - Does one include the other?
Do this 5–6 times with different grant types until it is automatic. Use the path matching table in Resource Paths as a reference.
Example to trace
Grant: network-group/NG-1 API call: GET /networks/NET-2 → targetPath = network-group/NG-1 → network/NET-2 Does network-group/NG-1 include network-group/NG-1 → network/NET-2 ? → YES — grant is a prefix of the target → AUTHORIZED Now try: Grant: network-group/NG-1 → network/NET-2 API call: GET /networks/NET-9 → targetPath = network-group/NG-1 → network/NET-9 Does it match? → NO — paths diverge at network level → 403
Exercise 2 — Decode a real NF JWT end-to-end
Grab a token from a real API call, paste it at jwt.io, find the 5 grant claims, pick one ID and follow it all the way to the DB:
-- Find the StandardRole row
SELECT * FROM StandardRole
WHERE id = UNHEX(REPLACE('<uuid-from-jwt>','-',''));
-- Find the path (ancestry)
SELECT * FROM StandardRoleResourceAncestry
WHERE standardRoleId = UNHEX(REPLACE('<uuid>','-',''));
-- Find who else holds this role
SELECT * FROM IdentityStandardRole
WHERE standardRoleId = UNHEX(REPLACE('<uuid>','-',''));
-- Find what the role template says
SELECT SRT.* FROM StandardRoleType SRT
JOIN StandardRole SR ON SR.standardRoleTypeId = SRT.id
WHERE SR.id = UNHEX(REPLACE('<uuid>','-',''));
Do this until tracing a JWT claim to its DB rows feels natural.
Exercise 3 — Run ActionInclusion mentally
Pick network-group / Admin. For each resource type (network, endpoint, service), manually apply the includeWhenAbove/At/Below rules and write out the resulting action list. Then verify using the API:
GET /auth/v1/authorizations?identityId=<uuid-of-a-network-group-admin>
If your manual expansion matches the API response, you understand ActionInclusion.
GET /auth/v1/authorizations?identityId=<uuid> — run it for an NFADMIN, an NFSUPPORT user, a regular org user, and a service identity. Compare the outputs side by side. The differences immediately show how each grant type expands differently.
Stage 2 — Read the Code in the Right Order (Week 2)
Don't read randomly. Follow the request lifecycle in this exact order. For each file: read the test first, then the implementation.
AuthorizationLogic.java
How POST /exchange builds the NF JWT. This is where DB grant rows become compact {id, version} references in the token. Understand why no permission expansion happens here.
AuthorizationContext.java
How downstream services parse the NF JWT from the Authorization header into a thread-local context. Note the lazy evaluation — nothing is expanded until first access.
AuthorizationSetBuilder.java
The most important file in the client jar. Expands each grant ID into the full domain → resourceType → action → paths map. Read the withStandardRoleGrant() method carefully — this is where ActionInclusion rules fire.
Authorization.java
The actual yes/no decision. Pure static utility. Read isAuthorized() and then isOwnerIdentityAuthorized(). This is the core concept in code form.
ResourceAncestryService.java
How resource paths are stored as linked-list rows and reconstructed into ResourceIdPath objects. Read storePath() and getPath(). Understand why ancestry rows are inserted leaf-first.
JwksService.java
RSA key pair management in Redis. Why Redis and not the DB? What happens during the 24h rotation window when old tokens arrive signed by the previous key?
*ApiDocTest classes (one per controller) show real HTTP requests and expected responses. They are the best documentation of actual service behavior — better than reading the controllers directly.
Stage 3 — Make Changes (Week 3–4)
Reading is passive. You become an SME by breaking things and fixing them. Do each of these exercises end-to-end.
| Exercise | What it teaches |
|---|---|
| Add a new resource type end-to-end: migration → ActionInclusion rows → verify in /authorizations |
How all 4 DB layers connect. The exact migration pattern every new resource follows. |
| Add a custom action to an existing resource type and grant it to NFSUPPORT | How CustomRole migrations work. The fixed UUID pattern for well-known roles. |
| Write a test where a request with a wrong path is rejected with 403 | Path prefix matching internals. Forces you to construct ResourceIdPath objects manually. |
Manually revoke a StandardRole (deletedAt the IdentityStandardRole row), wait for the next token refresh, verify access is gone |
The 5-minute consistency window. Why short JWT TTL is the revocation mechanism. |
| Trace a 403 from a real downstream service back to the missing grant | The full debugging workflow. Use /authorizations, JWT decode, and DB queries together. |
| Onboard a new internal service: create service identity, write migration to grant it the actions it needs | Inter-service permissions. Why service identities use IdentityResourceAction not StandardRoles. |
./mvnw test uses H2 in-memory — no external services needed. A passing test suite means your migration is syntactically correct and the service still loads. The *ApiDocTest classes will catch regressions in authorization behavior.
Stage 4 — Own the Hard Problems (Month 2)
These are the areas where SME-level knowledge lives. Most engineers never need to touch these — but an SME must own them.
Hard Problem 1 — computeIncludedAuthorizations
The role inclusion calculation — which StandardRoles are subsets of others. Read AuthorizationSetBuilder around the withStandardRoleGrant() method, specifically the included-authorizations block.
- It is O(n²) over all actions — expensive at scale
computeIncludedAuthorizations = falsein the client jar (every downstream service)computeIncludedAuthorizations = trueonly inside the auth service itself (for theGET /authorizationsendpoint)
Question to answer: What would break if you turned this on in downstream services?
Hard Problem 2 — Owner-Identity Dynamic Grants
The Contributor pattern — an identity can manage resources they own without an explicit per-resource grant. Read Authorization.isOwnerIdentityAuthorized().
- Actions at or above the grant's resource type depth: always authorized (normal permissions)
- Actions below the grant's resource type: only if
requestingIdentityId == resourceOwnerIdentityId create*actions are always allowed for contributors (you can create under your scope)
Question to answer: If the ownerSupplier is null (calling code doesn't pass one), what happens? Why?
Hard Problem 3 — In-Memory Cache Consistency
Every downstream service holds the full auth DB in memory via InMemoryMarkDeleteBeanRepository. Understand the full consistency guarantee:
- Cache is loaded at startup from MySQL
- Every incoming request checks grant versions from the JWT against the cached versions
- If JWT's
lm(lastModified) is newer than the cached version → cache entry is refreshed from MySQL before the authorization decision - The
HandlerInterceptorcallscheckVersions()on every request
Question to answer: What is the window during which a revoked grant could still pass the check? Under what conditions could it be longer than 5 minutes?
Hard Problem 4 — JWKS Key Rotation
RSA key pairs are stored in Redis (not MySQL) and rotated every 24 hours. Multiple auth service instances share the same Redis key map.
- On rotation: new key pair is generated and written to Redis; the old public key is retained briefly so in-flight tokens remain valid
- On shutdown: the public key is removed from Redis (tokens signed by that instance can no longer be validated)
- The JWKS endpoint (
GET /auth/jwks) serves the current set of public keys — downstream services use this to validate incoming NF JWTs
Question to answer: During a rolling deployment (old instance shutting down, new instance starting), what is the failure window for token validation? How is it mitigated?
Stage 5 — Become the Go-To Person (Ongoing)
SME status is not a certificate — it is earned by being consistently useful to others. These activities compound over time.
Answer 403 questions from other teams
When Core Management, Customer Management, or any MOP service hits a 403 they can't explain, be the person who traces it back to the missing grant. Every debugging session teaches you something new about the permission model.
Debugging workflow:
- Decode the NF JWT — which identity, which grant claims?
- Call
GET /authorizations?identityId=<uuid>— what does the expanded set look like? - Check the target path the service is building — does the grant path prefix it?
- If grant is missing entirely — check
IdentityStandardRole/IdentityCustomRolerows in DB - If grant exists but path doesn't match — check ancestry rows
Review every auth migration PR
Every time a new resource type, custom action, or permission grant is added to the platform, it goes through a Flyway migration in this repo. Reviewing these PRs keeps you current on every permission addition and catches common mistakes:
- Missing
ActionInclusionrows (new resource type won't work with Standard Roles) - Missing NFSUPPORT grant (support staff can't access the new resource)
- Wrong path scoping in ancestry rows
- Duplicate
IdentityStandardRole/IdentityCustomRolerows
Own the service onboarding flow for new MOP services
When a new internal service joins the platform, someone needs to:
- Create its Service Identity UUID
- Write the migration granting it exactly the actions it needs
- Verify it works end-to-end in staging
Owning this flow forces you to deeply understand inter-service permissions and the principle of least privilege in practice.
Write and maintain runbooks
Crystallise your knowledge into operational runbooks for common tasks:
- How to grant NFADMIN to a new identity
- How to revoke access immediately (and what the consistency window is)
- How to add a new resource type end-to-end
- How to debug a 403 in any MOP service
- How to verify a service identity's permissions
Generated from source:
authorization-dev · Covers auth service v2.x