Authentication
Every route except /health, /metrics and POST /webhooks/clerk needs
Authorization: Bearer <token>. The webhook is called by Clerk, takes no bearer
token (one sent there is ignored), and proves itself by the Svix signature on its
body (below).
There are three kinds of credential because there are three kinds of caller. The first two want opposite things; the third is a person signed in through Clerk (see below).
An app API key (fol_…) belongs to one application and can only ever touch
that application. It is the credential your service carries: the orders
service holds the key for the orders app, starts runs, answers tasks and reads
its own history. If it leaks, the blast radius is one app. Give an app key to
anything running unattended.
An operator token (from FOLIOT_OPERATOR_TOKENS) belongs to a person and
can act on every app. It is what Foliot Console and a human on a terminal
use. Cross-app work is the point: “which workflows are stuck right now”, “pause
that step across both services”, “register a new app”. An app key cannot do
those things by construction, because it cannot see past its own app.
The split exists so the credential deployed to production is not the credential that can create apps and read every tenant’s history. They are not tiers of the same thing — one is narrow and long-lived, the other is broad and held by a human.
- Operator only — registering an app with
POST /apps. Creating tenants is not something a tenant does to itself. Rotating an app’s API key is an operator token, or an owner of the app’s workspace (below); never an app key, because a credential must not mint its own replacement. - Operator only — also listing every workspace on the server with
GET /workspaces(how many members, owners and apps each holds;needsOwner=truelists the ones that have no owner and need one), and making a workspace withPOST /workspaces, which names its first owner by email address. - Either, narrowed to the app — everything else. An app key reaches only its own app: asking about another is a 403, and listing apps returns just itself rather than refusing.
A user session (a Clerk session token) belongs to a person who belongs to one or more workspaces, and each app belongs to one workspace. The token proves who the person is and nothing else: what they may do is read from their membership on every request, so a removed member loses access on their next call and a changed role applies at once.
- viewer reads: apps, workflows, runs and their history, bulk jobs, health.
- operator also changes things: starts runs, cancels, redrives, rewinds, skips, edits state, signals, pauses and resumes steps, registers workflows, runs bulk actions.
- owner also handles credentials, settings and who belongs to the workspace: reveals and rotates the signing secret, rotates the API key, reads the credential history, updates the app (its handler URL and description), and lists the workspace’s members, changes a member’s role and removes a member (below). Nothing else asks for it. Revealing the signing secret, rotating it and rotating the API key are recorded in the credential history with the person’s own identity as the actor, and the reason travels in the request body. Updating an app and reading the history are not recorded there.
A bulk action keeps running after the request that started it, so it keeps asking:
before every run it re-reads the person’s membership, and it stops (status failed,
with the reason) as soon as they are removed, demoted below operator, or the app leaves
their reach. The run already in flight finishes; none after it is touched.
A person can never reach the routes meant for services: claiming and answering
tasks, the unpaged GET /apps, registering an app with POST /apps, and the
operator’s GET /workspaces. They are
403 for a person whatever their role. The credential and settings routes above
answer a member with a role below owner 403, and everything about the operator
token and the app’s own key on them is unchanged (an app key still reveals,
rotates the signing secret, reads its history and updates its own app, and still
cannot rotate an API key). An app in a workspace the person is not a member of
answers 404, byte for byte as an app that does not exist does, so the answer
cannot be used to find out which apps exist; only a member, who can already see
the app, is told 403 when their role is too low. A person lists the apps of their
workspaces, and no others, with GET /me/apps, paged by limit and cursor; GET /apps
is unchanged and is for an operator or an app key. GET /me tells a person who they are
and which workspaces they belong to, with their role in each, read from the database on
every call; a person in no workspace gets an empty list, not an error.
The Clerk webhook (POST /webhooks/clerk) keeps the people Foliot knows in step
with their Clerk accounts: user.updated refreshes the name and email of a person
who is already here, and user.deleted removes the person and every membership they
hold. Clerk holds no organizations here, so every organization.* and
organizationMembership.* event, like any other type it does not act on, is
acknowledged with 204 and ignored. The route exists only when
FOLIOT_CLERK_WEBHOOK_SECRET is set. It is authenticated by its Svix signature,
verified against the raw body before anything is parsed or stored; every delivery
that cannot be verified gets the same 400, and every verified one that is read gets
204 whether or not it changed anything (an event that would have taken access away
and cannot be read gets 500, so Clerk redelivers it). Roles are managed in
Foliot, never in Clerk: no role in a Clerk payload is read, and the webhook never
adds a person to a workspace or changes anyone’s role.
Members. An owner manages who belongs to their workspace, under
/workspaces/{workspaceId}/members: GET lists the members and their roles (paged by
limit and cursor), PUT .../{userId}/role changes a role, and DELETE .../{userId}
removes a member. userId is Foliot’s id for the person, from the list. These are for an
owner of that workspace or an operator token; a member with a lower role is 403, a
workspace the caller is not in is 404 exactly as one that does not exist, and an app key
is refused. A change applies to the member’s very next request. None of them adds anyone:
people arrive by invitation (POST /workspaces/{workspaceId}/invitations, with the role
the owner chooses, joined when they sign in with a verified email address that matches),
and changing the role of someone who is not a member is 404.
A workspace’s only owner cannot be demoted or removed, by anyone, the operator token
included (409): make someone else an owner first. The caller’s own role is read again at
the moment of the change, so an owner who was demoted or removed a moment earlier changes
nothing. Removal leaves the person’s Clerk account alone; to let them back an owner
invites them again.
A person with the operator or owner role registers an app into their workspace with
POST /workspaces/{workspaceId}/apps. The app belongs to that workspace from the
statement that creates it. A workspace the person is not in answers 404, byte for byte
as one that does not exist does. An operator token may register into any workspace, and an
app key never may.
An app has a name and an id. The id is what every route, API key and SDK config names
the app by. For an app a person registers into a workspace the id is generated (app- and 16
characters) and the name they typed is only a display name, unique within that workspace and
compared case-insensitively. So two workspaces can each have an app called orders, a name
another workspace uses is not an obstacle and gives nothing away, and an app in another
workspace still answers 404 everywhere. For an app the operator registers with POST /apps
the name is the id, as it always was. A workspace’s app cannot be reached by its name: use the
id from the create response.
The credential history (GET /apps/{appId}/audit) names who acted twice. actor is
the stable identity as it was recorded and never changes: an operator token’s name,
app:<id>, or user:<clerk id> for a person. actorName is what to show, worked out
when the history is read: a person’s current name where one is known, and otherwise
exactly the text of actor (an operator, an app, a person with no name on file, an
account since deleted). A person chooses their own name, so it can imitate an app or an
operator and two people can share one: actorName is a label, never an identity.
actorKind (user, app or operator) is worked out from the recorded actor alone, so
no name can change it; show the two together, and use actor to tell people apart.
Where a handler may live
Foliot POSTs each task to your app’s handlerBaseUrl, so the URL decides where the server
connects. For an operator token it is unrestricted at registration. For anyone else (a
person registering into a workspace or updating an app as an owner, or an app key
updating its own app) it must be an
https URL with no credentials in it, and not a loopback, private, link-local or localhost
and .internal address; otherwise the request answers 400. That is the early check. The
connection itself is checked again each time a task is pushed, for every app, against the
address actually connected to, so a host name that resolves to a private address, or starts
to later, is refused with the run error class blocked_address and is not retried. By default,
in every environment, the server connects only to public addresses unless the operator lists a
range in FOLIOT_HANDLER_ALLOW_CIDRS. A development server asks for the open policy with
FOLIOT_HANDLER_EGRESS=open (refused on a production-looking deployment); under it the
registration check above is reduced to “an absolute http(s) URL without credentials”, and a
handler on localhost works. For every app and every environment, link-local addresses (which include the
cloud metadata service), AWS’s IPv6 metadata address and the unspecified address are never
connected to, and no allowed range reopens them. Redirects from a handler are never followed:
a 3xx is a failed attempt. A proxy is never used. A failed connection is reported in the
run as fixed text (handler unreachable, handler host name did not resolve), never the
network error itself; the detail is in the server log.
The two secrets have opposite lifecycles
An API key is stored only as a hash. The plaintext exists at registration and at rotation, and nowhere else, ever. Lose it and your only option is to rotate.
A signing secret is what Foliot signs pushes with and your handler verifies
against, so it must be readable. It is stored envelope-encrypted and leaves the
server through exactly one route, POST /apps/{appId}/signing-secret/reveal,
which requires a reason and records who asked before returning anything. It is
never included in any other response. Rotate it with
POST /apps/{appId}/signing-secret/rotate, which keeps the old secret verifying
for a rollover window so handlers can be updated without a flag day.