foliot docs

Concepts / Authentication

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=true lists the ones that have no owner and need one), and making a workspace with POST /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.