Authorization
This page describes how piqueld decides what a signed-in caller may do. The user guide’s permissions page lists what each permission allows; authentication covers how callers sign in.
Grants
piqueld_core::access holds the model, shared by the daemon, the dashboard
and the CLI:
Permissionisadmin, anAppPermission, or aGlobalPermission. The latter two each come from apermissions!table of wire names and descriptions, andPermission::descriptionaddsadmin’s. The dashboard’s grant editor shows each description next to its checkbox.- A
Scopeis every application (including later ones) or a set of application IDs. Installation-wide permissions can only have the whole installation as their scope, which the types enforce. Grantsis a validated set in canonical form, so equal access compares equal: grants for one permission merge, nothingadmin(orapps:deployandapps:delete, for their previews permissions) already covers is listed again, andapps:readlists every application readable through other application permissions. On the wire it is a list ofGrants, each with anapplicationslist that is absent for every application.Presets only fill in grants; what is stored is the explicit list.read-onlyanddeveloperaddsystem:readwhen their scope is every application.
An account’s access is the union of its grants. A credential’s access is its
own grants, if any, intersected on every request with its account’s current
grants (Grants::intersection), so demoting an account demotes its tokens.
Checking a request
Grants::require_appchecks one permission on one application. An application the caller cannot read at all isDenied::Hidden, answered like a missing application (404), so its existence doesn’t leak; a readable one without the permission isDenied::Missing, 403permission_deniedwith the permission indetails.permission.Grants::require_changechecks a mutation’s permissions against aTarget:Target::Newrequiresapps:createin place ofapps:write, and every other required permission on some application, such asapps:deployto create and deploy in one step, or to enable deploying on push.Target::Namedis an existing application named by the caller, such as in a manifest. One the caller cannot read is refused as if it were being created: 403 naming the first permission creating it would need and the caller lacks,apps:createor, for a change that deploys,apps:deploy, andapps:writefor callers holding both. So callers who could not create the application cannot tell taken names apart.Target::Previewlets previews permissions stand in for the permissions they cover (AppPermission::on_previews):previews:deployreads a preview’s logs. Deploying and deleting previews have their own routes, which require the previews permissions directly; reconciling a preview, or deploying it through/environments/{id}/deploy, still needsapps:deploy, andDELETE /environments/{id}apps:delete.Target::Unknown, an application or environment that does not exist or a malformed ID, passes only for callers holding the permission on every application, and is otherwise hidden.
- Requests about an environment are checked on its application, so a grant on an application covers its environments and previews, including later ones.
Grants::may_change_accountapproves a change to another account only withaccounts:manageand every grant the target holds (Grants::covers);Grants::may_grantapproves handing out grants the caller holds.adminis only covered byadmin, because it includes permissions added later. Lackingaccounts:manageanswers 403permission_denied; a failedcoverscheck answers 403permission_exceeded.
Routes with an application {id} check the permission on it; routes without
one (analytics, operations) need the permission on some application and
narrow their results to Grants::app_scope. The OpenAPI document names each
operation’s requirement in its x-piqueld-access extension: public,
authenticated, or one permission. authenticated operations check
permissions in their handlers: apply and plan, events and the event stream,
diagnostics, notification destinations, the audit trail, builds, and the
account directory, management and device approval. The extension names a
single permission, so it omits combined checks: granting telemetry also needs
apps:read, and saving with deploy=true also needs apps:deploy.
What callers see
Applications a caller cannot read are left out of lists, history, builds,
analytics, the event stream, the ingress routes in system readiness
(service/system.rs), and the preview counts in system status;
preview_limit_reached details list only readable previews. Daemon-scoped
history and diagnostics require system:read; application history requires
events:read on that application. Event IDs and stream checkpoints are
installation-wide, and the stream sends ID-only messages to advance past
filtered-out events, so a reader can tell that hidden events happened, but not
what or where.
Some installation-wide views are not filtered by application: notification
delivery subjects and destinations’ application selections, for holders of
system:read or notifications:manage, and the audit trail’s targets, for
holders of audit:read. notifications:manage alone also lists
destinations, and a destination may select any application ID.
Writes re-read access in their transaction
Grants are read at every authentication. Every write (account changes,
passkeys, tokens, application mutations, secrets, key recovery, notification
destinations and retries) also re-reads the caller’s credential and grants
inside its transaction, so a request that authenticated just before a
demotion, revocation, or expiry cannot use the access it lost. Revoking a
credential, setting grants and deleting an account commit through
commit_access_change, which notifies long-lived connections.
Long-lived connections
Event streams and apps:exec command sessions are authorized when they open;
a Lease (api/http/lease.rs) then rechecks the caller’s access while they
last. Its first recheck is due at once, so a change made between
authentication and subscribing is not missed. The lease polls every second
(TICK) and rechecks when an access change was notified, or 30 seconds
(RECHECK) after the previous recheck started. The periodic recheck catches
what isn’t notified: an expired credential, grants removed with a deleted
application, creator grants, and a tailnet peer that no longer matches a
token’s binding, which each recheck looks up afresh.
- A stream switches to the caller’s new visibility, so it stops sending
history the caller may no longer read; once the caller loses access
entirely, it ends with an
access_endedmessage whose data is theErrorBodythe request would now get. - A command session that lost access ends with a
Failedframe carrying the refusal, such as 401, 403, or 404 once the application is hidden, which is audited. A 503tailnet_unavailablefrom a bound token’s lookup ends it too, unaudited. Like a disconnect, this only stops the stream: the command itself may keep running in the task. - Both end, without a message, when the daemon shuts down.
Limited credentials
API tokens are always limited (scoped), even with admin on every
application; CLI logins are limited whenever they asked for any grants, even
ones equal to the account’s full access. A limited credential is refused with
403 credential_scoped (Denied::Scoped) when it would create credentials
(tokens, passkeys, invitations, enrollment links, CLI login approvals), set
grants on any account, or change its own account. It may revoke itself. With
accounts:manage and a covering scope, it can still edit other accounts’
profiles, delete them, remove or rename their passkeys, and revoke their
credentials and invitations. A limited credential without audit:read reads
only its own requests in the audit trail, rather than its account’s.
Deleting an application removes the grants on it once the application row is finally deleted, after its last environment.
Limited credentials, tokens and CLI logins alike, left without any grant expire a minute later, or earlier if they already would. Until then they can still reach their own account, but no application.
The minute lets a piquelctl app delete run with such a token see its
deletion finish (404) rather than lose its session (401).
Creator grants
Creating an application requires apps:create. Grants::for_created_application
gives the creator, on the new application, every application permission (and
admin) it holds on some but not all applications, so it can work on what it
created without gaining any new kind of ability. Permissions it already holds
everywhere need nothing new. An account whose only permission is apps:create
therefore receives nothing and cannot read the application it created.
Applications created through a limited credential give no creator grants,
since those credentials hand out no lasting access.
Links act with their issuer’s authority
Invitation and enrollment links store their issuer. At redemption, the store
checks that the issuer could still create the link: still holds
accounts:manage and every grant the invitation hands out, or may still
change the enrollment’s target account. A refused link answers 400
authentication_invalid, like an expired or used one, and stays listed until
it expires. Creating a link needs a credential with its account’s full access.
Revoking one needs the authority to have created it, and a full-access
credential only for an enrollment link to the caller’s own account, like every
change to it. Deleting the issuer deletes its links (ON DELETE CASCADE), and
deleting an enrollment’s target account deletes its enrollment links.
Lockout protection
Setting grants, deleting an account and removing a passkey are refused with
409 account_lockout when no account would keep admin on every application
together with a passkey (store/auth/access.rs). The host operator is not an
account and never counts, and the rule applies to its changes too. Revoking a
host operator browser session requires accounts:manage and admin on every
application.
Links between applications
Links have no permission checks of
their own: exports and imports are configuration edits (apps:write), and
deploying them needs apps:deploy (previews:deploy for a preview). A
preview’s imports come from its branch or local snapshot, so whoever may
deploy previews chooses them. An export names applications, not IDs: an
unbound name matches whichever application holds it until the exporter’s next
deployment binds it, and a deleted application’s binding passes to its name’s
next holder only at the exporter’s next deployment. Link reports read
unavailable for both a missing environment and one not exported to the
importer, and importer lists are filtered to readable applications.
Telemetry receivers
PUT /api/v1/environments/{id}/telemetry requires telemetry:grant on the
route and apps:read in the mutation. Without telemetry:grant it answers
403 even for hidden applications; with it, hidden applications and previews
answer 404. Revoking stops export before the response. Telemetry status lists
only the receivers of readable applications.
Tailnet-bound tokens
A token may carry a TailnetBinding: a login name (containing @, up to 256
bytes) or tag: followed by letters, digits and dashes. Creating one needs
the daemon’s tailnet node (tailscale.enabled) or a dashboard hostname
(TokenPolicy::tailnet), since it could never be used otherwise. For a bound
token, FreshTailnet::check looks the connection’s peer up afresh through the
node’s tailscaled (the apps node’s, for the dashboard hostname) on every
request, again when an app exec command arrives, and on every lease recheck.
A lookup that times out answers 503 tailnet_unavailable: LOOKUP_TIMEOUT,
5 seconds, applies both to waiting behind another lookup of the same address
and to its own query, so the worst case is about 10 seconds. Any other
mismatch, including requests from the Unix socket or other listeners,
which have no peer, answers 401 tailnet_binding_mismatch, and the refusal is
audited. Tagged nodes have no login, so a user binding never matches them.
CLI logins cannot be bound.