Feature Specification: Authorization of Calls to api

Created: 2026-09-23

Status: Implemented

Input: Reconstructed from the code at 0bf4ecd36 (panel/src/panel/tokens.py, views.py (ApiToken, ReconcileToken, AuthzCallback, CentrifugoConnect, CentrifugoSubscribe, CentrifugoPublish, DeploymentListProxy, DeploymentStateProxy), api_client.py, nix/openapi-client.nix, static/centrifuge_session.js), api/specification.md (sections "Authorization" and "Events"), pull requests #992, #1011, #1036, #1249 and #1903, and the design notes of the maintainers.

User Scenarios & Testing (mandatory)

User Story 1 - The browser calls api as the operator (Priority: P1)

The page asks the panel for a bearer token (POST /api/token/), and calls api directly with it. api sends the procedure, the scope and the request headers to the authorization callback of the panel (POST /api/authz/). The panel finds the operator from the token, and allows the call only when the operator owns the scope. For a list without identifiers, the panel allows the call and gives the identifiers of the deployments of the operator, and api lists only those.

Why this priority: api does not know users. Without this callback, no call to api is safe.

Independent Test: run panel-python-tests (test_deployment_async.py, test_api_client.py).

Acceptance Scenarios:

  1. Given a logged-in operator, When the page asks for a token, Then the panel gives a signed token and its expiry time. An anonymous request gets no token.
  2. Given a valid token of operator A and a scope with the identifier of a deployment of A, When api calls the callback, Then the panel answers {allow: true, sub: "<pk of A>"}.
  3. Given a valid token of operator A and a deployment identifier of operator B, When api calls the callback, Then the panel answers {allow: false}.
  4. Given a list of deployments with one or more deployment_ids in the scope, When api calls the callback, Then the panel allows the call only when each identifier is of a deployment of the operator.
  5. Given a valid token of operator A and a list of deployments without deployment_ids in the scope and with accepts: ["deployment_ids"], When api calls the callback, Then the panel answers {allow: true, sub: "<pk of A>", deployment_ids: [<the ids of the deployments of A>]}.
  6. Given a schema or rename lookup, When api calls the callback with a valid token, Then the panel allows the call.
  7. Given a token that is missing, expired or has a wrong signature, When api calls the callback, Then the panel answers {allow: false}.

User Story 2 - api calls on behalf of the operator (Priority: P2)

For an internal call, api has checked its own service token, and sends internal: true. The panel then takes the operator from the X-On-Behalf-Of header, and applies the same rules.

Independent Test: run panel-python-tests (test_deployment_async.py).

Acceptance Scenarios:

  1. Given internal: true and X-On-Behalf-Of: A, When the scope is a deployment of A, Then the panel allows the call.
  2. Given internal: true without X-On-Behalf-Of, When api calls the callback, Then the panel answers {allow: false}.

User Story 3 - The browser follows the events of its own deployments (Priority: P1)

The browser connects to Centrifugo with the session cookie of the panel. Centrifugo asks the panel to accept the connection and each subscription. The panel accepts only the channels fediversity.deployments.<pk> of the deployments of the operator, and refuses each publish from a client.

Independent Test: run panel-python-tests (test_centrifugo_proxy.py).

Acceptance Scenarios:

  1. Given a logged-in operator, When Centrifugo calls connect, Then the panel gives the primary key of the user. Without a session, the panel refuses.
  2. Given a channel of another operator, When Centrifugo calls subscribe, Then the panel refuses.
  3. Given any client, When Centrifugo calls publish, Then the panel refuses.

User Story 4 - The server calls api for the operator (Priority: P2)

For the pages that the panel renders, the panel calls api with the client that is generated from the OpenAPI document of api, and with a token of the operator. The proxies deployments/api/ and deployments/<pk>/state/ give the browser the list and the state as JSON.

Independent Test: run panel-python-tests (test_api_client.py, test_api_down.py).

Acceptance Scenarios:

  1. Given a page that shows state, When the panel calls api, Then the call carries a token of the operator, and api authorizes it through the callback.
  2. Given that api does not answer, When a proxy is called, Then the proxy answers with an "unreachable" error, not a server error.

Edge Cases

  • The browser token expires during a long deploy: the page keeps the token until shortly before its expiry time, and then asks for a new token.
  • A reconcile token is valid for one deployment only, and for a longer time (RECONCILE_TOKEN_TTL), because a destroy can take long.
  • A token of one kind is not valid as a token of the other kind (separate salts).

Requirements (mandatory)

Functional Requirements

  • FR-001: The panel MUST mint signed bearer tokens with a short lifetime (API_TOKEN_TTL) only for a logged-in operator.
  • FR-002: The authorization callback MUST allow a call only for an operator that owns the deployment identifier, or each of the deployment identifiers, in the scope, except the schema and rename lookups, which need only a valid operator. It MUST match an identifier only in the form str(pk). For a list without identifiers, it MUST allow the call with the identifiers of the deployments of the operator as deployment_ids, only for deployment_list, and only when accepts of the request has deployment_ids. Else it MUST refuse a list without identifiers.
  • FR-003: The authorization callback MUST refuse a call that has no operator.
  • FR-004: The event proxy callbacks MUST limit each operator to the channels of their own deployments, and MUST refuse publishes from clients.
  • FR-005: The panel MUST call api only through the client generated from the OpenAPI document of api, or through the documented HTTP interface.
  • FR-006: The authorization callback MUST allow only the procedures of a list that the panel knows, and MUST refuse all other procedures. A test MUST compare the list with the OpenAPI document of api.
  • FR-007: The authorization callback MUST accept calls only from api.

Key Entities

  • API token: a signed token that names an operator, salt panel.api-token.
  • Reconcile token: a signed token that names a deployment, salt panel.reconcile-token.
  • Authorization request: {procedure, scope, headers, internal?, accepts?} from api. The answer is {allow, sub?, deployment_ids?}. accepts lists the fields of the answer, besides allow and sub, that api applies.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: No call to api with the token of one operator reads or changes a deployment of another operator.
  • SC-002: No client can publish to an event channel.

Assumptions

  • api checks its service token before it sends internal: true, and removes the token from the headers.
  • Only api publishes events, through the admin API of Centrifugo.
  • The panel component generates the callback token, and the api component reads it from OpenBao.

Known Deviations

None.