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
OPTIONSpreflight 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. authzrefuses, 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:
- Write the permit in that form, with the action named in
action ==oraction in [...], thewhenjoined by&&if it has other conditions, and a@scopes. The generator refuses any other use ofcontext.allowed_grants, a catalogued permit without a valid@scopes, two permits that give one action different@scopes, and an action that another permit grants withoutcontext.allowed_grants. - 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. - Run
make generateand commit the updatedservices/identity/internal/actions/catalog.generated.json. The pull request pipeline fails if you forget. - 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.internalThe 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 tokenActing 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.