Flow
Sign-in and authorization

Building a service

Verify access tokens with jwtauth, check permissions with Cedar through authz, and call other services with a client_credentials token.

A service does two things with every request: it verifies the caller's access token, then it checks whether the caller may do the action. It also needs its own identity when it calls another service. This page covers all three using the sample service, services/sample, whose code is the reference for every step below. Its README has the steps to run it.

The sample service runs locally only and is never deployed. Its OAuth2 clients are registered on dev and locally. It accepts its own audience, https://sample.flow.internal, and the platform audience, https://api.flow.internal. No deployed service uses common/jwtauth or common/authz yet.

Choose an audience

An audience names who a token is for. Your service has its own, https:// and a lowercase host with nothing after it, such as https://ledger.flow.internal. It is not a real address, and it must never be an OAuth2 client_id.

There is also one platform audience, https://api.flow.internal. Web apps that sign a person in always use it, so one token works at every service that accepts it. If apps call your service, accept both audiences. A token is accepted when its aud contains either one, and a token minted only for other services is refused.

Other services that call you as themselves register an audience on their OAuth2 client: yours alone, a list of up to 20 services that includes yours, or the platform audience. See Call other services as yourself below, and docs/decisions/0055-platform-api-audience.md for why.

Verify tokens with jwtauth

Create one verifier at startup and mount its middleware in front of every route that needs a caller. The sample does it in services/sample/cmd/api/main.go and services/sample/internal/handlers/router.go:

verifier, err := jwtauth.New(jwtauth.Config{
	JWKSURL: cfg.TokenJWKSURL, // https://authn.dev.apps-and-services.com/.well-known/jwks.json
	Issuer:  cfg.TokenIssuer,  // https://authn.dev.apps-and-services.com
	// The service's own audience, then the platform audience apps use.
	Audiences: []string{cfg.TokenAudience, cfg.PlatformAudience},
})
if err != nil {
	return err
}

r := httpapi.PlatformRouter(hc) // health routes, no token needed
httpapi.MountAPI(r, hc, []func(http.Handler) http.Handler{jwtauth.Middleware(verifier)},
	func(api huma.API) { registerOperations(api, h) })

The middleware answers 401 before your handler runs when the token is missing, expired, signed with an unknown key, from another issuer, or for no audience you accept. When it passes, the verified sub is the actor FID: The id of a principal. For a person it is their Kratos identity id, a lowercase UUID; for a service it is its OAuth2 client_id. It arrives as the verified sub claim of a Hydra access token and becomes the actor on the request., which the rest of the request reads through reqmeta.ActorFID(ctx). jwtauth.ClaimsFrom(ctx) gives the client_id too, which tells a service's token (where sub equals client_id) from a person's.

Rules that matter when wiring it in:

  • Never run another middleware that sets the actor, such as a development header, in the same chain. It would replace the verified actor.
  • Health checks and OPTIONS preflight requests carry no token, so mount them outside the middleware.
  • The issuer is compared exactly. A trailing slash makes it a different issuer.
  • Locally, the JWKS URL is http://127.0.0.1:4444/.well-known/jwks.json. Plain http is accepted only for a loopback host.
  • Never forward a token you received. A person's token is accepted by every service that accepts the platform audience, so whoever holds it can act as that person anywhere until it expires. Use it only to call your own handlers. When you call another service, send your own service account's token.

Signing keys are cached for one hour, which is also how long a removed key keeps working. The package doc in common/jwtauth/jwtauth.go covers the cache and every check.

Check permissions with authz

A token says who is calling. Whether they may do this is decided by Cedar policies that live in your service and are compiled into the binary.

Write policies

Policies go in a policies/ directory embedded with embed.FS. Every policy needs an @id and a @doc, and the service refuses to start if one is missing or two share an id. A policy lists its actions and says how the resource is scoped, but never names a role:

@id("sample-workorder-reads")
@doc("The caller holds a role allowed for this action, at the work order's building.")
@scopes("building")
permit (
  principal,
  action in [
    Action::"sample::GetWorkOrder",
    Action::"sample::ListWorkOrders"
  ],
  resource is WorkOrder
) when {
  principal.grants.containsAny(context.allowed_grants)
};

@id("sample-workorder-writes")
@doc("The caller holds a role allowed for this action, at the work order's building or at global scope.")
@scopes("building, global")
permit (
  principal,
  action in [
    Action::"sample::CreateWorkOrder",
    Action::"sample::CloseWorkOrder"
  ],
  resource is WorkOrder
) when {
  principal.grants.containsAny(context.allowed_grants)
};

@scopes lists the scope types your handler passes for these actions: global, building or unit, separated by commas. The sample's handlers pass the building for reads, and the building and global:* for writes, so the reads say building and the writes say building, global. Two things read it:

  • The identity service's GET /v1/authz/me, which tells an app what the caller may do, reports an action at a scope only when the scope's type is listed.
  • authz refuses, with an error rather than a deny, a check that passes a scope whose type is not listed.

Keep @scopes exactly what the handlers pass. If it is wider, identity tells an app the person may do something that every call then refuses, and nothing catches that. If it is narrower, the check fails with an error.

The sample's services/sample/policies/workorders.cedar also has a forbid that refuses to close a work order that is already closed. A forbid that reads an attribute checks it is there first, with resource has status.

Which roles may call sample::GetWorkOrder is data, in the endpoint_access table: one row per (action, role). Which people hold those roles is grant data in the identity service. Neither needs a release to change.

Roles and actions

A role is a name such as dispatcher. An action is one operation on one service, such as sample::GetWorkOrder. A role can do only the actions it has been given, and only where it is granted: at one building, at one unit, or everywhere. An action that no role has been given is denied to everyone.

An administrator manages this in users-app, under Roles. They create a role, for people or for service accounts, open it, and tick the actions it may do. A role for service accounts can be given only to service accounts, and any service-account admin can give it to an account whose secret they hold, so tick its actions with that in mind. Identity stores what they tick, and every service picks up the change the next time it refreshes its copy, about a minute later, with no deploy. Giving the role to a person is a separate step, on the person's page, where the administrator also picks the scope.

The actions users-app offers are the action catalog. It is built from every service's policies by go run ./cmd/genactioncatalog, which make generate runs, and it lists an action only when a permit policy grants it through principal.grants.containsAny(context.allowed_grants), as in the policy above. Policies that name roles directly are left out. So for your action to appear:

  1. Write the permit in that form, with the action named in action == or action in [...], the when joined by && if it has other conditions, and a @scopes. The generator refuses any other use of context.allowed_grants, a catalogued permit without a valid @scopes, two permits that give one action different @scopes, and an action that another permit grants without context.allowed_grants.
  2. Add a description and a kind for the action to your service's services/<service>/policies/descriptions.json. The full contract for that file is in "The action catalog" section of services/identity/README.md.
  3. Run make generate and commit the updated services/identity/internal/actions/catalog.generated.json. The pull request pipeline fails if you forget.
  4. Deploy identity. The catalog is built into identity, so an action appears in users-app only once identity is deployed with it.

The identity README, "Roles and actions", has every rule.

Identity's own admin actions, named identity:: (identity::GrantRole, identity::SetRoleActions, and so on), are catalogued the same way, so identity decides who may call its own admin routes with Cedar, like any other service. Only a platform admin can tick an identity:: action onto a role, and never onto a role for service accounts, so no machine token can administer identity. See docs/decisions/0059-identity-authorizes-its-own-admin-requests.md.

Check in the handler

// policies/embed.go
//go:embed *.cedar
var FS embed.FS

checker, err := authz.NewChecker(policies.FS, store)

// In the handler, after jwtauth has run:
err := checker.CheckWithResource(ctx,
	"sample::CloseWorkOrder",
	authz.Entity{Type: "WorkOrder", ID: id.String(), Attrs: map[string]any{"status": rec.Status}},
	authz.Scopes("building:"+building.String(), "global:*"),
)
switch {
case authz.IsDenied(err):
	return nil, response.NewError(ctx, http.StatusForbidden, "forbidden") // no reason given
case err != nil:
	return nil, err // 500: the check could not be made
}

CheckWithResource returns nil only when the request is allowed. Any other result means do not continue. A plain net/http handler answers a deny with authz.WriteDenied(w, r), which writes the same body.

Read a single resource with the scope the caller was checked at as well as its id. The sample reads a work order with both its id and the building from the path, so a caller allowed at building A who sends the id of a work order in building B gets a 404, not that work order.

The scopes are the ones the resource belongs to. global:* is never added for you: pass it when a global grant, such as platform-admin@global:*, should reach this resource, and leave it out for an action only a building's own staff may do. The sample passes it for creating and closing, and leaves it out for the reads.

The check refuses before Cedar runs when the caller has no principal, the principal is not active, or the action has no endpoint_access rows. The full list of rules is in the package doc, common/authz/authz.go.

Where grants come from

NewChecker reads principals, grants and endpoint_access rows from a Store. The database store reads a local copy of identity's principals, grants and endpoint_access rows in the service's own database, refreshed periodically, as described in docs/architecture.md.

common/principalcopy/doc.go keeps that copy. Copy principalcopy.Schema into your migrations, run a Refresher against identity's GET /v1/principals/snapshot with your service's own machine token, mount its ReadyHandler as your readiness probe, and pass principalcopy.NewStore to NewChecker. Your service's client must be a system principal in identity holding principal-reader at global scope; the identity README, "Principal snapshot", has the steps, and no such grant is made on dev yet. Each refresh replaces all three tables, endpoint_access included, so never write endpoint_access rows yourself: they are set in users-app. The sample service wires all of this in services/sample/cmd/api/main.go, and its tests load the copy from a stub identity with RefreshOnce, so they check against the same store the service runs with.

Call other services as yourself

When your service calls another one on its own behalf, not a person's, it uses its own OAuth2 client and the client_credentials grant.

Get a machine client

There are two ways to get one.

A service account, made in the user admin app. This is the usual way. Under Clients, a platform admin or a service-account admin creates the account:

  • a name, which becomes the client_id svc- and the name;
  • a description;
  • an owner;
  • its audiences: the platform audience alone, or 1 to 20 registered service audiences.

The secret is shown once, in the dialog that confirms the account. Store it straight away in Key Vault or wherever your service reads it from. It is never shown again, and a lost secret means rotating it. identity creates the account's system principal at the same time.

The account can get tokens at once but can do nothing yet. On the account's page, give it a role for service accounts, at a building or at global scope. The account can hold only such roles, never a role for people, and never platform-admin or service-account-admin.

From the same page you can rotate the secret, which makes the old one stop working at once, disable the account or delete it. See docs/decisions/0056-self-service-service-accounts.md.

A client in the repository. The platform's own clients, such as the snapshot readers, are defined in services/authn/hydra/clients/dev.json (and local.json to use one locally), and every deploy applies them. This is the sample service's entry:

{
  "audience": ["https://sample.flow.internal"],
  "client_id": "flow-sample-service",
  "client_name": "Flow sample service",
  "client_secret_ref": "hydra-client-flow-sample-service",
  "grant_types": ["client_credentials"],
  "metadata": { "first_party": true, "managed_by": "repo" },
  "scope": "openid",
  "token_endpoint_auth_method": "client_secret_basic"
}

The audience is the service you want to call. A client that calls several services lists each of their audiences, up to 20, or uses the platform audience https://api.flow.internal if it calls most of the platform; never both. The file never holds the secret. client_secret_ref names it, and the secret itself goes into Key Vault and the encrypted Hydra client secrets file, as described under Adding a client in services/authn/README.md and in services/authn/.azure/env/readme.md. Your service reads the same secret from its own Kubernetes Secret.

Every client in a file is tagged managed_by: repo, and its client_id may not start with svc-, which is kept for service accounts. The deploy changes only clients tagged repo, never a service account, and prints a report of every other difference it finds in Hydra.

The client_id becomes your service's FID. For the service you call to allow you anything, a client from the repository needs a system principal in the identity service, ensured by hand, and a grant of a role for service accounts. The seeded roles are for people only, and principal-reader cannot be given actions. Locally, the sample's services/sample/scripts/setup-local.sh adds the system role principal-sample-caller and grants it to flow-sample-service.

Get and reuse a token

curl -X POST {issuer}/oauth2/token \
  -u flow-sample-service:<secret> \
  -d grant_type=client_credentials \
  -d audience=https://sample.flow.internal

The secret goes in the Authorization header (HTTP Basic), never in the body or the URL. The token lasts one hour. Cache it and reuse it until shortly before it expires, rather than asking for one per request.

A client registered for several audiences asks for the ones it needs, separated by spaces in audience, and the token names all of them. Ask for only what the next call needs: one service per token when that is enough, several only when one token must be used at each. Any service named in a token can replay it to the others named in it.

In Go, golang.org/x/oauth2/clientcredentials does the caching for you:

tokens := (&clientcredentials.Config{
	ClientID:       "flow-sample-service",
	ClientSecret:   secret, // from the environment, never logged
	TokenURL:       issuer + "/oauth2/token",
	EndpointParams: url.Values{"audience": {"https://sample.flow.internal"}},
	AuthStyle:      oauth2.AuthStyleInHeader,
}).Client(ctx) // an *http.Client that adds and renews the token

Acting for a person

When a person's request makes your service call another one, do not forward the person's token. Call the other service with your own service account's token. A forwarded token would let your service act as that person at every service that accepts the platform audience, and nothing in the other service's Cedar check could tell the difference. If a service ever needs to act as the person, the answer is token exchange, which is not built; see docs/decisions/0055-platform-api-audience.md.

On this page