TLDR: right now auth is nearly non-existent, and introducing it in a phase where everything is moving requires identifying the pieces we have to get right and the stuff that's flexible
The following outlines what I think would be a good model to go forward with, to let the albedo daemon become a control plane for agents, with a proper multiplayer surface enabled by a flexible auth layer.
Every caller has an identity, carries a token that identifies it's capabilities. Those capabilities allow it to access a host. Hosts run kernels.
Callers can be defined by whatever, namely their transport layer (e.g. allow everyone in your tailnet), or map atproto/iroh identities to capabilities (or roles?) and then let sessions or boards be readable or multiplayer per cap.
The actual proposal is under here, but it's kinda big (good for agents but I haven't polished it 100%).
Scope #
Three things are in scope: who sends a request, what the sender can do, and where a kernel runs.
The scope also covers what the tree has today, and which standards the rules lean on.
The HTTP format is out of scope. docs/openapi.yaml owns the routes and the payloads.
Requirements #
This document says must, must not, should, and may when it means a requirement.
Each rule is a test.
One word means one action in every section. send, read, write, start, stop, store, check, and allow keep one meaning throughout.
Words #
The third column answers one thing: does this name already exist? Where it does, the document uses it. Where it doesn't, the column says so, and marks the word as standard or proposed.
| Word | Meaning | Name today |
|---|---|---|
| Home | The folder $ALBEDO_HOME and the data of one person: credentials, memory, sessions, worktrees, and artifacts. One person owns one home. |
$ALBEDO_HOME |
| Session | One conversation with the agent, its kernel, and its files. | session |
| Kernel | The Python process that runs a session's tools. | kernel, placed by python/place.gleam |
| Artifact | A web page the agent makes for a person to open. | artifact |
| Hop | The program that takes the connection and hands the request to the daemon: the web server, the TUI, the proxy, the webhook intake, or another daemon. | No name today. Proposed here. It ain't the hop-by-hop of RFC 9110. |
| Hop key | The password a hop shows the daemon. It lists the principals that hop can assert. | No name today. Nearest thing is web-key, the web server's own. |
| Launch link | The link albedo web prints. The key in it makes the owner's first ticket. |
webui.LaunchLink |
| Assertion | A hop's statement that a request comes from a named principal, sent with the evidence. | No name today. SAML and OIDC say the same. |
| Principal | The identity the daemon allows. Five kinds: owner, member, device, session, and service. | No name today. The standard security word. |
| Ticket | A secret the daemon stores. It names one principal, one scope, the verbs, and the end time. | Nearest word is token, as in config.token and attachment_token. A Kerberos ticket is the same idea. |
| Scope | What a ticket can open: the home, one session, one artifact, or one host. | attachment_token already limits one route this way. |
| Permission | A record that lets one principal do one verb on one object. | No name today. Not grant: oauth.Grant is the authorization-code grant. |
| Verb | view, drive, or share. |
Kubernetes RBAC says the same. |
| Board | A group of sessions and artifacts with a shared view, for two or more people. | links and presence. Proposed here for the shared view. |
| Host | The record for one machine that can run a kernel. | hosts.gleam |
| Proof | Why a host is the machine its record names: an ssh host key, an iroh node id, or an instance id with a process id. | No name today. ssh says host key, iroh says node id. |
| Runner | The part of the daemon that starts, joins, and stops a kernel on a host. | No name today. place.gleam places it, relays.gleam carries it. |
| Admission | What the check on one request returns: principal, ticket, scope, body limit, and the origin answer. | extension.Admission |
Six names don't exist in the tree: hop, hop key, principal, assertion, board, and proof. Four come from a standard or a system that solved the same problem. Two get proposed here: hop and board.
Data #
The ticket fields:
| Field | Type | Meaning |
|---|---|---|
id |
string | A fixed name. The daemon revokes by it and keeps a record by it. |
secret |
bytes | The part the sender shows. The daemon compares it in constant time and sends it one time only. |
principal |
Principal | One principal. |
scope |
Scope | One scope. |
verbs |
set of Verb | The most the ticket allows. A permission can allow less. |
end_time |
time | The daemon takes the ticket only before this time. |
The host fields:
| Field | Type | Meaning |
|---|---|---|
id |
string | A fixed name. Session.kernel_host points at it. |
kind |
local, ssh, iroh |
The transport. The kind picks the Runner. |
address |
string | The transport address for that kind. |
password |
secret | The Runner's password for this machine. |
user |
string | The UNIX user that runs the kernel. |
proof |
Host proof | The Runner checks it when it connects. |
One function answers "can this principal do this verb on this object":
may(principal, verb, object) -> bool
What exists today #
Each part gets one question: does the tree have it, or does the contract add it?
exists means the tree has it. partial means the tree has a form of it under another name. none means the contract adds it.
Every claim names the file it comes from.
| Part | State | Today | Missing |
|---|---|---|---|
| Daemon credential | exists | One token per home. src/albedo/daemon/server.gleam compares it with !=. |
A stored row, a scope, and a revocation. |
| Browser sign-in | exists | The key in the launch link becomes a cookie in cli/internal/webui/server.go. |
A principal on the ticket. |
| Proxy key | exists | cli/internal/proxyserver/server.go compares it in constant time. |
One shape with the other credentials. |
| Object capability | exists | attachment_token is checked at each attachment route in http_sessions.gleam and http_acp.gleam. |
A scope on a ticket instead. |
| Webhook signature | exists | extensions/webhooks/service.gleam returns SignedBody for one POST route. |
One admission shape with the rest. |
| Principal | none | The daemon holds one token for one home. | The five kinds in the words table. |
| Allowlist | none | --allow gates the connection. The web cookie gates the browser. A browser that passes both is the owner, through toDaemon. |
A list of DIDs and tailnet users. |
| Origin check | partial | Three checks: webui.Access.Accept, webui.forward, and the daemon. The daemon takes a request with no origin header. |
One rule. |
| Origin comparison | partial | The daemon compares exact strings against ALBEDO_HTTP_ORIGINS. The web server compares parts after ParseOrigin in cli/internal/webui/origin.go. |
One comparison for both. |
| CORS headers | partial | server.gleam for a request and a preflight, http_stream.gleam, and webui.frameHeaders. |
Headers from one decision. |
| Admission | partial | Admission(authorization, body_limit) in src/albedo/harness/extension.gleam, chosen by each extension. LocalAccess needs no credential and stays safe only on loopback. |
A principal and a scope in the value. |
| Remote kernel | exists | A workspace like user@host:/path starts the kernel there. location.gleam gives Remote and ssh_target; session.gleam and extensions/python/place.gleam use it. |
A host separate from the path. |
| Host connection | exists | src/albedo/daemon/relays.gleam keeps one ssh connection per host, a helper program, and a token for the daemon there. |
A Runner interface. |
| Host list | partial | src/albedo/daemon/hosts.gleam lists ssh targets from recent workspaces and ssh.config_hosts(). It never probes a host. |
An id, a kind, and a record. |
| Host proof | partial | The daemon reports an instance id from albedo_http_api.erl. ssh checks host keys in its own known hosts file. |
A record that ties a host to a proof. |
| Sharing | partial | src/albedo/harness/links.gleam and extensions/links/presence.gleam. |
Permissions and boards. |
| Revocation | none | There ain't one. The owner replaces the credential: a new link, a new proxy key, or a restart. | A revocation and an end time. |
Two facts matter more than the table.
webui.toDaemon strips the origin, the cookie, and the referrer, then adds the home's token. So the daemon can't be telling a member from the owner.
A remote kernel works today. The workspace string is the machine's address. The contract moves the machine into its own field, so a workspace can be a plain path.
References #
Every claim about the tree names its file. A standard shows up here only where the rules take something from it, and each row says what they leave out.
Normative #
| Document | What the rules take from it |
|---|---|
| RFC 2119 and RFC 8174 | The words must, must not, should, and may. |
docs/openapi.yaml |
The routes and the payloads. This document doesn't restate them. |
Informative #
| Document | What the rules take | What the rules leave out |
|---|---|---|
| RFC 9110 | The meaning of 401, 403, and 421. The web server already sends 421. | Other status codes. |
| RFC 6750 | The WWW-Authenticate: Bearer challenge the daemon already sends on 401. |
The bearer token format. A ticket is opaque, so there's nothing to parse. |
| RFC 7662 | The shape of an opaque lookup: a secret becomes a principal and a scope. | An endpoint. The daemon is the only reader of its own tickets. |
| RFC 7009 | The shape of a revocation: one request ends one token. | An endpoint, and a token type hint. |
| RFC 8693 | The shape of minting: a caller trades an assertion and its own credential for a token for the subject. | The protocol and the token type registry. One route on the daemon does this. |
| OpenID Connect Core 1.0 | The code flow, the issuer, and the subject claim. | An ID token as a credential. The ticket stays the only credential. |
| The AT Protocol OAuth specification | The DID as the subject, which is what the allowlist stores. | DPoP (RFC 9449). The provider asks the client for it, and albedo never holds that key. |
| RFC 6454 and the Fetch Standard | The form of an origin: lower case scheme, no default port, no path. | The rest of the CORS protocol. |
Tailscale LocalAPI whois |
The node and the tailnet user of an address the hop saw. | Nothing. It answers one question, and the hop trusts the socket, not the request. |
Rules #
The check on a request #
- The daemon builds an Admission for each request before any handler reads it. No Admission, no route.
- It builds the Admission before it reads the body.
- A handler never reads a secret. It reads the principal, the scope, and what
maysays.
Tickets #
- One ticket, one principal, one scope. Hand it two of either and the daemon won't take it.
- The daemon stores every ticket and compares the secret against the stored value in constant time.
- A revocation lands on the next request. The daemon may cache a ticket for 30 seconds at most.
- The secret shows up once, in the answer that makes the ticket. Nowhere else.
- The daemon makes a ticket only for a valid hop key that lists that principal.
Origin #
- One origin header, or none. Two or more is an error.
- The origin must be the host that got the request, or an origin on the hop's list. Anything else is an error.
- No origin header? The daemon takes the request only if the rule doesn't need a browser origin.
- CORS headers come from the origin answer. A browser route sends
access-control-allow-originandvary: Origin. - An identity header counts only from a hop key that names that header.
ForwardedandX-Forwarded-Fornever identify anybody.
Assertions #
- A hop asserts a principal with evidence the sender can't fake: a hop key, an OIDC or atproto exchange, or a
whoisanswer about the address the hop saw. - A tailnet assertion gives a device principal from the node in the
whoisanswer. Configuration is the only thing that makes a device a member. - A hop speaks only about an address it saw. Behind a proxy, it takes that proxy's assertion and skips the second lookup.
- A hop takes evidence only from the home's allowlist. The allowlist says which principal each piece of evidence gets.
- The hop checks the allowlist at sign-in. A ticket that already exists works until its end time or a revocation.
What a sender can do #
- A handler opens an object only when the ticket's scope covers it and
mayallows the verb. Either check alone ain't enough. - No permission, no home scope: a principal can't read an object in another home.
Kernels #
- A session's kernel starts on its
kernel_host. The workspace path picks no machine. - A Runner brings its own ticket. The daemon never starts a kernel on somebody's personal ticket.
- A Runner checks the host proof before it sends data. Wrong proof, no kernel.
Errors #
| Code | Status | Condition |
|---|---|---|
authentication_required |
401 | The route needs a ticket and the request has none. |
ticket_unknown |
401 | The secret matches no stored ticket. |
ticket_expired |
401 | The ticket is past its end_time. |
hop_unknown |
401 | A minting request carries no valid hop key. |
origin_forbidden |
403 | An origin rule fails. |
forbidden |
403 | The scope doesn't cover the object, or no permission allows the verb. |
Where identity comes from #
| Source | Evidence | Principal | Configuration |
|---|---|---|---|
| Launch link | The key in the link albedo web prints. |
owner | $ALBEDO_HOME/web-key |
| Forward proxy | A header from a hop with a valid hop key. | member | The header name and the hop key |
| OIDC | A code exchange with the issuer. | member | The issuer, the client id, the claim map, and the allowlist |
| atproto | An OAuth exchange with the atproto provider, which gives a DID. | member | The issuer, the client id, and the allowlist |
| Tailnet | A whois answer about the sender's address. |
device, or member from the allowlist | The socket, and the allowlist |
| Device ticket | A ticket file in the home. | device | The ticket's end time |
Tailnet assertions #
The tailnet vets the machine before the connection lands. To get here, the machine needs a node key that the tailnet's access rules allow. A hop turns that into an assertion and trusts nothing inside the request.
It asks the local tailscaled about the address it saw:
tailscale whois --json <address>
The answer names a node (StableID, Name) and a tailnet user (ID, LoginName). The hop makes a device:<node id> principal and sends the tailnet user as evidence. Three limits:
- The hop that saw the address does the lookup (rule 16).
- A node is a device, not a person (rule 15). One machine can serve two people, and the answer never names who's at the keyboard.
- No socket, or an address outside the tailnet, means no assertion. The request goes on as an ordinary client, because the same listener takes other transports.
The daemon uses one peer record for the assertion and for the Host record. So a machine a person signs in from is also a machine that can run a kernel. Host.proof holds the node id.
Allowlist and examples #
The allowlist is the only place a remote identity becomes a principal.
allowlist
atproto did:plc:kar… = owner
atproto did:plc:aly… = member:aly
tailnet kar@example.com = owner
tailnet aly@example.com = member:aly
| Arrives | Allowlist | Principal | Result |
|---|---|---|---|
tailnet user kar@example.com |
owner | owner | home scope |
tailnet user aly@example.com |
member:aly | member:aly | the boards shared with her |
tailnet user ana@example.com |
no entry | device, no verbs | 403 forbidden on each object |
10.0.0.5, no whois answer |
not asked | none | the launch link, or 401 authentication_required |
atproto did:plc:aly… |
member:aly | member:aly | the same permissions as row 2 |
atproto did:plc:other… |
no entry | none | no ticket, and the sign-in page says not allowed |
The list holds DIDs, not handles. A handle is a domain name, and it can move to another account.
The hop reads the allowlist at sign-in (rule 18). Drop an entry and the next sign-in fails. A live ticket keeps working until its end time or a revocation.
Changes #
Each change gets a current state, a target, and tests.
Change A: send the real identity #
Current: webui.toDaemon sends the home's token on every browser request.
Target: the hop sends a ticket for the principal it checked. The web server holds a hop key for owner and member. Sign-in picks the principal and makes the ticket.
Tests:
- A member with a
viewpermission reads the session in the web client. - The same member gets
403 forbiddenfor a session with no permission. - A TUI request with the home's ticket reaches the same session, because
ownerhas the home scope. - The daemon records one principal per request. No request gets the owner's identity by default.
Change B: a host is not a path #
Current: a session already runs its kernel on another machine when its workspace names one, like user@host:/path. The workspace string is the machine's address, so a session can't have both a plain path and a remote kernel.
Target: a session has workspace, which is a path, and kernel_host, which names a host record. The daemon reads the record, checks the proof, and starts the kernel with a Runner.
Tests:
- A session names a host by id and keeps
/home/kar/repoas its workspace. The kernel runs on that host. - A host whose proof differs from its record gets no kernel.
- Two sessions on one host share one connection.
- A session with no
kernel_hostruns on the daemon's machine. That's today's behaviour for a local workspace. - A child agent gets the parent's
kernel_hostunless the creation names another.
Steps #
| Step | Change | Test that ends the step |
|---|---|---|
| 1 | Tickets and principals: one credential shape, one check, change A. | An integration test takes a member ticket and rejects an object with no permission. |
| 2 | Hosts and kernel_host: the record, the Runner, change B. |
A session names a host by id, keeps a plain workspace path, and runs its kernel there. |
| 3 | Hop keys and sources: forward proxy, then OIDC, then tailnet. | A sign-in from each source gives a ticket with the right principal. |
| 4 | Permissions and boards: three verbs, one check, a shared board. | Two clients on one board see each other. |
Step 1 and step 4 share the check. Step 2 and step 3 don't touch each other, or the pair.
Out of scope #
Three verbs and one function. That's the permission model. A generic design works for the provider list because the list is data. It doesn't work for permissions, because a rule language turns into a second system with its own bugs.
The browser boundary stays in the web server: it checks a browser and makes tickets. A hop key is what makes a ticket valid, not the program that holds it. Move the boundary to a sidecar or into the daemon and these rules don't move.
A home belongs to one principal. Nobody shares one.
Questions #
A few calls left I haven't decided
- Do a device ticket and a tailnet node share one principal kind, or does each device name its owner?
- How long does a browser ticket live, and does it refresh with the hop key or with a new sign-in?
- Does loopback give a principal at all? Say no, and a request on loopback with no ticket is
authentication_required. That touches every current client path at once. - What does a tailnet device get by default: nothing, or
viewon boards published to the tailnet? - Where do tickets live: the home's SQLite store, or
creds.jsonbeside the provider credentials?