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:

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.

Key Insight Authorization in NetFoundry is path-based. A grant doesn't just say "Alice can read networks" — it says "Alice can read networks under organization ABC." This hierarchical scoping is what makes multi-tenant security possible.

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 CodeMicroservice AreaWhat 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:

LayerTableAnswersExample
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
Public read access All authenticated identities can read Domains, Resource Types, and Resource Actions without any explicit grant. This is configured as a PublicGrant in the database.

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

organization  (depth 0 — root, parentId = null)
  ├─ 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

network-group  (depth 0 — root, parentId = null)
  ├─ 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 depthAction's resource typeRelationshipFlag 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
Practical consequence of depth: A "Network Group Admin" grant (depth 0) covers create/read/update/delete on networks, endpoints, services — everything below. A "Network Admin" grant (depth 1) covers only that one network and its children, not the group itself or sibling networks.

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)

CodeNameMeaning
createCreateCreate a new instance of this resource
readReadView/list instances of this resource
updateUpdateModify an existing instance
deleteDeleteRemove an existing instance

Custom Actions (examples)

Resource TypeCustom Action CodeMeaning
edge-routerupdate-healTrigger a heal/resize operation
edge-routerupdate-versionUpgrade firmware version
edge-routerupdate-suspendSuspend the edge router
networkupdate-moveMove network to a different host
networkupdate-backupBackup network data
user-identityupdate-add-adminPromote a user to admin
user-identityupdate-reset-mfaReset 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 PathTarget PathAuthorized?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)
Important nuance A grant with path 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

Organization (e.g., "ACME Corp", id=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
Organization vs Domain These are different things. A Domain is an authorization namespace (like 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:

TypeCodeWho 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.

PriorityTypeScopeUse 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
}

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
}
Caution SuperUser grants bypass all authorization checks. Only assign this to system-level root accounts (NFADMIN). Never assign to regular users.

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 TypeActionWhy
geo-regionreadConsole UI needs to show geo regions to everyone for network creation
data-centerreadSame — data center listing is public info
domainreadAuth clients need to discover domain definitions
resource-typereadAuth clients need to know what resource types exist
resource-actionreadAuth clients need to know what actions exist
No path scoping PublicGrants have a 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

NameStatic IDPurpose
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 conceptAuth system conceptDB 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

LayerChanges when
DomainA new microservice area is added to the platform
ResourceTypeA new kind of thing is added to a service
ResourceActionA new capability is added to a resource type
ActionInclusionA policy decision changes — e.g., "Admins should no longer delete their own org"
StandardRoleTypeA new role concept is needed — e.g., "Billing Admin"
StandardRoleA new customer org or network-group is provisioned
IdentityStandardRoleA user is granted or revoked access
Layers 1–5 are immutable config seeded by migrations. Layers 6–7 are runtime data created by the provisioning service and admin API calls.

The Three Questions a Standard Role Answers

QuestionTableCreated
"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.
The naming in plain English:
  • 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

DomainResource TypeStrategyWhat it grants
Networknetwork-groupAdminFull CRUD on network group and everything below (networks, endpoints, services)
Networknetwork-groupRead-OnlyRead access to everything in the network group
NetworknetworkAdminFull CRUD on that network and its children
NetworknetworkRead-OnlyRead-only on that network and its children
IdentityorganizationAdminFull CRUD on org and all identities within it
IdentityorganizationRead-OnlyRead access to org and all identities
Authstandard-roleAdminCan create and grant standard roles
Authcustom-roleAdminCan 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)

StrategyActionAboveAtBelowMeaning
Read-Onlyread✅✅✅Can read at any depth
Adminread✅✅✅Admin can always read
Admincreate❌❌✅Can only create resources below the grant level
Adminupdate❌✅✅Can update at and below grant level
Admindelete❌❌✅Can only delete resources below grant level
Example Alice has "Network Admin" at 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

1
Alice opens Console UI and clicks Login
→
2
Redirected to Auth0, enters credentials
→
3
Auth0 returns access_token (aud: gateway)
→
4
Console calls POST /exchange
→
5
Auth Service issues NF JWT with grant refs

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:

  1. Parse Alice's NF JWT → extract grants-standard claim → get [{id: sr-alice-ng-acme-uuid, v: 1234567}]
  2. Resolve via AuthorizationSetBuilder: look up sr-alice-ng-acme-uuid → StandardRole scoped to network-group/ng-acme-uuid with strategy Admin
  3. Expand via ActionInclusion: Admin includes read at and above → Alice can read at network-group/ng-acme
  4. Authorization.isAuthorized(): grant path network-group/ng-acme is a prefix of target path network-group/ng-acme → network/xyz → ✅ AUTHORIZED
  5. 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.

ActionAboveAt (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

Org Admin gives zero network permissions. The 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.

DomainProtects
io.netfoundry.identityOrganizations, user-identities, API accounts, service identities, invitations
io.netfoundry.networkNetwork-groups, networks, endpoints, appwans, services, edge resources
io.netfoundry.authThe permission model itself — standard roles, custom roles, grants, super-users
io.netfoundry.billingBilling accounts
io.netfoundry.managementManagement service resources
io.netfoundry.entitlementEntitlement and licensing resources
io.netfoundry.appFrontdoor / application resources
One sentence summary: Domain is the fence. ResourceType is the room. StandardRole is the key. Org Admin is the key to the identity rooms — it does not open any network rooms.

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 UUIDWhoWhen
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.

The JWT carries StandardRole IDs — not IdentityStandardRole IDs. The 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

StageTriggerRow createdShared 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

InformationDBQueried 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
The permissions DB only stores WHERE a grant is scoped. It never stores which individual resource instances fall under that scope. That knowledge lives in the service that owns the resource. The auth check assembles the full picture at request time by combining the JWT grant references with the path built by the downstream service.

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.

This is not a role NFADMIN is not a role type in the standard role model. It is a direct entry in the 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

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.

The chicken-and-egg problem To create a SuperUser via 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 config
  • identity.tenant — NFADMIN org record
  • identity.IdentityProvider — Google OAuth provider
  • identity.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 with domainId = 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
);
Multiple people can share the Root User Add one 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 Domain records (io.netfoundry.network, io.netfoundry.identity, io.netfoundry.auth)
  • All ResourceType trees (network-group, network, endpoint, organization, user-identity, …)
  • All ResourceAction rows (create, read, update, delete + custom actions)
  • All StandardRoleType rows (Admin, Read-Only for every resource type)
  • All ActionInclusion rules
  • All PublicGrant rows (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:

TaskAPI call
Give another person NFADMIN accessAdd a new IdentityMapping row (still manual — identity service only) or POST /auth/v1/super-users for a separate identity
Create a new customer orgIdentity Service API
Grant a user org adminPOST /auth/v1/standard-role-grants
Create new resource types / actionsFlyway migration SQL (auto-applied on next deploy)
Create custom roles (NFSUPPORT etc.)Flyway migration SQL

Bootstrap Data Flow

1
identity.realm
Auth0 config
→
2
identity.tenant
NFADMIN org
→
3
identity.IdentityProvider
google-oauth2
→
4
identity.UserIdentity
NFADMIN Root User
→
5
identity.IdentityMapping
person's Google sub
→
6
Flyway migrations
auto on startup
→
7
permissions.SuperUser
domainId = NULL
Why the IdentityMapping is manual and not via an API The Identity Service's user creation APIs are designed for customer-facing workflows (invitations, user management within an org). The NFADMIN Root User is a special system identity that exists outside the normal user provisioning flow. Linking a real person's Google sub to it is a privileged operation with no equivalent API endpoint — it must be done directly in the DB by someone with DB access.

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

AspectNFADMINNFSUPPORT Intervention
Grant typeSuperUser (null domain)CustomRole (specific actions)
Access levelEverything, unconditionallyCurated set of support operations
Path scopingNone (bypassed)Can be scoped to specific customer
AuditabilityHard to audit specificsSpecific actions are enumerable
Adding new resourceAutomatic accessMust 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 atAction ondeltaDepthOwner 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  ?
ScenarioResult
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
Owner-identity only applies when the grant is scoped below a group level. If Alice has "Network Group Admin" (depth 0), all checks for resources at depth 1 or 2 use 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

1
User logs in via Auth0 (browser redirect)
→
2
Auth0 issues Auth0 JWT (aud: gateway, exp: 24h)
→
3
Console UI calls POST /exchange
→
4
Auth Service validates Auth0 JWT via JWKS
→
5
Looks up identity grants in MySQL
→
6
Issues NF JWT signed with RSA key in Redis (exp: 5 min)
→
7
Console UI uses NF JWT for all API calls via gateway
→
8
Downstream services validate NF JWT via /auth/jwks

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.

ClaimTypeContains references to
grants-publicPublicGrantPublic grants (same for all identities)
grants-superSuperUserSuperUser grants held by this identity
grants-actionActionGrantIdentityResourceAction grants held by this identity
grants-customCustomRoleCustomRole grants held by this identity
grants-standardStandardRoleStandardRole 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.

Key actors in this flow
  • 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"] }

This is NOT the NF JWT This token is a short-lived state envelope used to pass login context to Auth0. The real NF JWT with grant claims comes later, server-side.

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 stored code_challenge
  • client_id and redirect_uri match 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"
}
No grants in the Auth0 token The Auth0 token only proves WHO you are (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
  }]
}
Key revelation Prashant's Google account is an 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:

  1. Validates Auth0 JWT signature via Auth0's JWKS endpoint
  2. Extracts sub = google-oauth2|116549977878305133269
  3. Resolves via Identity Service → identityId = 6fa59db2-a1cb-11ef-874d-a29c2afc7246
  4. Queries permissions DB 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 sub as UUID → 6fa59db2... ✓ (Auth0 sub google-oauth2|... would fail here)
  • Reads grants-super claim → SuperUser with domainId=NULL
  • Authorization.isAuthorized() short-circuits → true for everything
  • Returns all networks across all customers

Complete Flow Summary

1
GET /organizations/label/NFADMIN
resolve org + realm
→
2
POST /authorize-initiate
PKCE + state JWT
→
3
Auth0 challenge
HRD → Google
→
4
Google OAuth
user signs in
→
5
POST /oauth/token
PKCE exchange → Auth0 JWT
→
6
POST /exchange
server-side: Auth0 JWT → NF JWT
→
7
GET /authorizations
UI permission set
→
8
Gateway token swap
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)
);
How to give another person NFADMIN access The NFADMIN Root User identity stays the same. You only need to add a new IdentityMapping row linking their Google (or other) Auth0 sub to the same UserIdentity UUID. No SuperUser insert needed — the existing one already covers it.
TokenIssued byContainsUsed forTTL
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
}
Prefer migrations for system-level custom roles Custom roles that are permanently part of the system (like NFSUPPORT) should be created and maintained via Flyway SQL migrations, not just REST API calls. This ensures they exist consistently across all environments.

Complete Checklist: Granting a New User Access

Here is the complete checklist to configure a new user from zero to having meaningful API access:

#StepServiceAPI
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>.

MethodEndpointDescription
POST/exchangeExchange Auth0 JWT for NF JWT
POST/masqueradeIssue NF JWT for a specific identity (service-to-service)
GET/authorizationsGet full authorization set for an identity
GET/auth/jwksGet RSA public keys for NF JWT validation
GET/domainsList all domains
GET/resource-typesList resource types
GET/resource-actionsList resource actions
POST/super-usersCreate a SuperUser grant
DELETE/super-users/{id}Remove a SuperUser grant
POST/public-grantsCreate a public grant
POST/identity-resource-actionsCreate a direct action grant
POST/custom-rolesCreate a custom role
POST/custom-role-actionsAdd an action to a custom role
POST/custom-role-grantsGrant a custom role to an identity
GET/standard-role-typesList standard role type templates
POST/standard-rolesCreate a standard role instance (scoped)
POST/standard-role-grantsAssign 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.

In-memory caching At startup the service reads all rows from every table into 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.

TablePurpose
DomainNamespace, e.g. io.netfoundry.network
ResourceTypeTree of resource classes with depth + parent
ResourceActionActions (read/create/update/delete/custom) per type
ActionInclusionRules: 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.

TableRolePurpose
StandardRoleTypeRole DefinitionTemplate: Admin/Read-Only/Contributor for a given ResourceType
StandardRole + AncestryRole InstanceOne stamping of the template onto a specific resource path
IdentityStandardRoleRole AssignmentWhich identity holds that instance
CustomRoleRole DefinitionNamed 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.

TableBinds
SuperUserIdentity → all actions in a domain (or all domains)
PublicGrantAll identities → a single action globally
IdentityResourceActionIdentity → one specific action on a path
IdentityCustomRoleIdentity → a CustomRole (on a path)
IdentityStandardRoleIdentity → 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.

TableScopes
StandardRoleResourceAncestryStandardRole → subtree path
CustomRoleActionResourceAncestryEach CustomRoleAction → subtree path
IdentityResourceActionResourceAncestryDirect action grant → subtree path
PublicGrantResourceAncestryPublic grant → subtree path (usually empty = global)

How a "Network Admin on NG-1" is stored across all four layers

Layer 1 — Vocabulary
  Domain: io.netfoundry.network
  ResourceType: 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=true

Layer 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":   "[]"
}
The version field (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

ColumnTypeDescription
idbinary(16) PKUUID
namevarchar(256)Human-readable name, e.g. Networks
codevarchar(128) UNIQUEDot-notation code used in authorization checks and JWT claims, e.g. io.netfoundry.network
createdAt / updatedAt / deletedAtDATETIME(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

ColumnTypeDescription
idbinary(16) PKUUID
domainIdbinary(16) FK → DomainWhich domain this type belongs to
namevarchar(256)Human-readable, e.g. Network
codevarchar(64)Kebab-case code used in checks, e.g. network
parentIdbinary(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

ColumnTypeDescription
idbinary(16) PKUUID
resourceTypeIdbinary(16) FK → ResourceTypeWhich resource type this action applies to
namevarchar(256)Human-readable, e.g. Create
codevarchar(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-heal on edge-router — trigger healing
  • update-resume, update-suspend on network
  • update-version on edge-router
  • create-client-token on api-account-identity
  • update-migrate on network

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

ColumnTypeDescription
idbinary(16) PKUUID
resourceActionIdbinary(16) FK → ResourceActionWhich action this inclusion rule applies to
authorizationStrategyNamevarchar(64)Admin, Read-Only, or Contributor — maps to AuthorizationStrategy
includeWhenAboveBITInclude this action when the Standard Role is granted at a resource type that is an ancestor of the action's resource type
includeWhenAtBITInclude when the grant is exactly at the action's resource type depth
includeWhenBelowBITInclude 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

ColumnTypeDescription
idbinary(16) PKUUID
resourceTypeIdbinary(16) FK → ResourceTypeThe resource type this template is for
authorizationStrategyNamevarchar(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-Only
  • network / Admin, network / Read-Only
  • organization / Admin, organization / Read-Only
  • standard-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

ColumnTypeDescription
idbinary(16) PKUUID of this grant
identityIdbinary(16)UUID of the identity who gets the god-mode access
domainIdbinary(16) NULLABLE FK → DomainNULL = full god-mode across all domains (NFADMIN). Non-null = god-mode limited to that domain.
createdAt / updatedAt / deletedAtDATETIME(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

ColumnTypeDescription
idbinary(16) PKUUID
resourceActionIdbinary(16) FK → ResourceActionThe specific action that is publicly allowed
isAnonymousBITIf 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-regions
  • data-center.read — public, anonymous — anyone can read data centers
  • domain.read, resource-type.read, resource-action.read — public (authenticated), so any service with a valid token can introspect the auth catalog
  • network-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

ColumnTypeDescription
idbinary(16) PKUUID of this grant
identityIdbinary(16)The identity receiving the direct action grant
resourceActionIdbinary(16) FK → ResourceActionThe 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

ColumnTypeDescription
idbinary(16) PKUUID — for well-known system roles this is a fixed UUID baked into migrations
namevarchar(256)Human-readable name, e.g. Read-Only, ALL Auth Domain Resources

CustomRoleAction

ColumnTypeDescription
idbinary(16) PKUUID
customRoleIdbinary(16) FK → CustomRoleThe custom role this action line belongs to
resourceActionIdbinary(16) FK → ResourceActionThe 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

ColumnTypeDescription
idbinary(16) PKUUID
identityIdbinary(16)The identity granted the role
customRoleIdbinary(16) FK → CustomRoleThe 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-resume but 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 startup
  • 97315ed6-2fc7-4a40-b7ea-abe53cc6a188 — NFSUPPORT Intervention — operational support role with heal/resume/suspend/version-update actions
  • 8f32a5aa-… — NFSUPPORT Observation — read-only support role

StandardRole, StandardRoleResourceAncestry & IdentityStandardRole

StandardRole

ColumnTypeDescription
idbinary(16) PKUUID
standardRoleTypeIdbinary(16) FK → StandardRoleTypeWhich template (Admin/Read-Only/Contributor for which resource type) this instance uses
createdAt / updatedAt / deletedAtDATETIME(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

ColumnTypeDescription
idbinary(16) PKUUID
identityIdbinary(16)The identity granted the role
standardRoleIdbinary(16) FK → StandardRoleThe 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:

  1. A StandardRole row pointing at the StandardRoleType for network-group/Admin
  2. A StandardRoleResourceAncestry row encoding the path network-group/NG-1-uuid
  3. An IdentityStandardRole row 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 TableParent ColumnWhat it scopes
StandardRoleResourceAncestrystandardRoleIdThe resource subtree that a Standard Role instance covers
CustomRoleActionResourceAncestrycustomRoleActionIdThe resource subtree that a single action line in a Custom Role covers
IdentityResourceActionResourceAncestryidentityResourceActionIdThe resource subtree for a direct action grant
PublicGrantResourceAncestrypublicGrantIdOptional path scope for a public grant (usually empty = global)

Ancestry Row Structure

Every ancestry table has the same column set:

ColumnDescription
idUUID PK
standardRoleId (or equivalent)FK to the owning grant row
childAncestryIdSelf-referential FK. Points to the next deeper ancestry row in the path chain. NULL = this is the deepest (leaf) element of the path.
resourceTypeIdFK → ResourceType — which resource type this path element represents
resourceIdThe 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.

Never manually insert ancestry rows Ancestry rows are managed exclusively via 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:

1
StandardRoleType
Admin of network-group
→
2
StandardRole
instance row (no path yet)
→
3
StandardRoleResourceAncestry
scoped to NG-1
→
4
IdentityStandardRole
Alice → SR row
→
5
NF JWT issued
with SR ID + version in claim
→
6
AuthorizationSetBuilder
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.

The entire check is in-memory and sub-millisecond From JWT parse to authorization decision, no database query occurs. The DB is only hit during write operations or when the version check on request entry detects that the in-memory cache is stale and needs refreshing.

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:

  1. Validates the Auth0 JWT (signature, expiry, issuer) using the live JWKS from Auth0
  2. Extracts alice-uuid-1234 from the https://netfoundry.io/service-identity/id claim
  3. Looks up all grants for Alice in the in-memory repositories (IdentityStandardRole, IdentityCustomRole, etc.)
  4. Builds a GrantReference set for each grant type
  5. 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.

Why 5-minute NF tokens? The short TTL is the consistency window. Permission changes (e.g., revoking Alice's Standard Role) take effect within 5 minutes without requiring any token revocation infrastructure. The DB update is instant; the NF token naturally expires. This is the deliberate design tradeoff: short TTL = eventual consistency without revocation lists.

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 Accounts vs. Service Identities An API Account (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:

  1. Create a Service Identity row in the Identity Service DB for the service (gets a fixed UUID)
  2. Create an Auth0 M2M application for the service
  3. Add an Auth0 Action that injects the Service Identity UUID into the token as https://netfoundry.io/service-identity/id
  4. Write a Flyway migration in authorization-dev that grants the service the exact actions it needs:
    • Use IdentityResourceAction rows for fine-grained, single-action grants
    • Or use IdentityCustomRole rows pointing at a well-known Custom Role (like the Auth Read-Only role)
  5. The migration runs on next deployment — the service can now call the target APIs
Never use Standard Roles for service identities Standard Roles (Admin/Read-Only) are designed for human-facing access with the broad, depth-based expansion via ActionInclusion. Service identities should use 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

AnnotationAction CodeWhat it checks
@AuthReadreadCaller can read the resource identified by the annotated ID
@AuthCreatecreateCaller can create a resource of the given type under the parent resource identified by the annotated ID
@AuthUpdateupdateCaller can update the resource identified by the annotated ID
@AuthDeletedeleteCaller can delete the resource identified by the annotated ID
@AuthActioncustom (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
Summary: The full journey from DB row to API access
  1. A DB row (IdentityStandardRole, IdentityCustomRole, etc.) is written by a REST API call or Flyway migration
  2. 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
  3. The NF JWT is presented to a downstream service's controller — the @Auth annotation fires
  4. The AuthorizationSet is built in-memory by resolving the grant ID against the local in-memory cache (itself synced from DB at startup + version checks)
  5. Authorization.isAuthorized() does a pure in-memory prefix path match to return true or false
  6. 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).

Mental model Think of Layer 1 as building the factory (resource types, action rules, role templates) and Layer 2 as running the factory for each new customer (stamping out role instances and handing them to Alice).

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:

NameResourceTypeStrategy
Organization AdminorganizationAdmin
Organization Read-OnlyorganizationRead-Only
Network-Group Adminnetwork-groupAdmin
Network-Group Read-Onlynetwork-groupRead-Only
Network AdminnetworkAdmin
Network Read-OnlynetworkRead-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 StandardRoles
  • standard-role / Read-Only — can read StandardRoles
  • custom-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.

Summary: what gets written to the DB for a new customer
  1. 2 rows in StandardRole (org Admin + org Read-Only) + ancestry rows
  2. 2 rows × N network-groups in StandardRole (ng Admin + ng Read-Only) + ancestry rows
  3. 2 Auth SR rows per Standard Role in step 1-2 + ancestry rows
  4. 1 or more rows in IdentityStandardRole assigning 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.

Actors
  • 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

idcode
dom-id-01io.netfoundry.identity
dom-net-01io.netfoundry.network
dom-auth-01io.netfoundry.auth

resource_type

idcodedomain_idparent_iddepth
rt-orgorganizationdom-id-01null0
rt-ngnetwork-groupdom-net-01null0
rt-netnetworkdom-net-01rt-ng1
rt-srstandard-roledom-auth-01null0

standard_role_type

idnameresource_type_idauthorization_strategy_name
srt-oaOrganization Adminrt-orgAdmin
srt-oroOrganization Read-Onlyrt-orgRead-Only
srt-ngaNetwork-Group Adminrt-ngAdmin
srt-ngroNetwork-Group Read-Onlyrt-ngRead-Only
srt-sraStandard-Role Adminrt-srAdmin
srt-srroStandard-Role Read-Onlyrt-srRead-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)

idstandard_role_type_idcreated_by
sr-oa-acmesrt-oaprov-0001
sr-oro-acmesrt-oroprov-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.

idstandard_role_idresource_type_idresource_idchild_ancestry_id
anc-oa-1sr-oa-acmert-orgacme-0001null
anc-oro-1sr-oro-acmert-orgacme-0001null

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)

idstandard_role_type_idcreated_by
sr-nga-devsrt-ngaprov-0001
sr-ngro-devsrt-ngroprov-0001

standard_role_resource_ancestry (new rows)

idstandard_role_idresource_type_idresource_idchild_ancestry_id
anc-nga-1sr-nga-devrt-ngdevng-001null
anc-ngro-1sr-ngro-devrt-ngdevng-001null

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)

idstandard_role_type_idnotes
auth-sr-1srt-sraAuth Admin wrapper for sr-oa-acme
auth-sr-2srt-srroAuth Read-Only wrapper for sr-oa-acme
auth-sr-3srt-sraAuth Admin wrapper for sr-oro-acme
……one pair per SR from phases 1-2

standard_role_resource_ancestry (new rows)

idstandard_role_idresource_type_idresource_idchild_ancestry_id
anc-auth-1auth-sr-1rt-srsr-oa-acmenull
anc-auth-2auth-sr-2rt-srsr-oa-acmenull
…………null
Why "standard-role" resource type? An Auth Standard Role points at a StandardRole as if it were a regular resource. By making StandardRoles first-class resources in the 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)

ididentity_idstandard_role_idcreated_bydeleted_at
isr-alice-1alice-0001sr-oa-acmeprov-0001null

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.

Net result Alice's single 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)
No permission expansion at exchange time The Auth Service does NOT expand grants into individual actions at token issuance time. It only embeds IDs + versions. The expansion (consulting ActionInclusion, resolving ancestry) happens in Phase B, inside every downstream service.

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 LayerUsed in PhaseContributes
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 two invariants to remember
  1. 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.
  2. 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.

Honest timeline Mental model solid in 1 week. Code readable in 2 weeks. Confident making changes in 1 month. True SME in 2–3 months. The shortcut is the GET /auth/v1/authorizations?identityId=<uuid> endpoint — 30 minutes with it teaches more than 2 hours reading code.
StageTimeYou can do
1 — Mental ModelWeek 1Explain auth to anyone, trace a grant from DB to JWT
2 — Read the CodeWeek 2Follow the request lifecycle, debug most 403s
3 — Make ChangesWeek 3–4Add resource types, write migrations, write tests
4 — Hard ProblemsMonth 2Own edge cases: owner-identity, key rotation, cache consistency
5 — Go-To PersonOngoingAnswer 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 ResourceIdPath does the grant produce?
  • What ResourceIdPath does 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.

The best learning tool in the entire service 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?

Use the ApiDocTest classes as your entry point The *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.

ExerciseWhat 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.
Run the full test suite after every migration ./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 = false in the client jar (every downstream service)
  • computeIncludedAuthorizations = true only inside the auth service itself (for the GET /authorizations endpoint)

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 HandlerInterceptor calls checkVersions() 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:

  1. Decode the NF JWT — which identity, which grant claims?
  2. Call GET /authorizations?identityId=<uuid> — what does the expanded set look like?
  3. Check the target path the service is building — does the grant path prefix it?
  4. If grant is missing entirely — check IdentityStandardRole / IdentityCustomRole rows in DB
  5. 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 ActionInclusion rows (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 / IdentityCustomRole rows

Own the service onboarding flow for new MOP services

When a new internal service joins the platform, someone needs to:

  1. Create its Service Identity UUID
  2. Write the migration granting it exactly the actions it needs
  3. 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
The compound effect Each 403 you debug teaches you one more edge case in the path matching logic. Each migration you review teaches you one more pattern. After 2–3 months of this, you will have seen enough real-world cases that no authorization problem will surprise you.


NetFoundry Authorization Service — Internal Documentation
Generated from source: authorization-dev · Covers auth service v2.x