Authentication & Authorization¶
Reference for Artel's auth middleware (artel/server/auth.py). Every REST route
depends on one of the dependency aliases at the bottom of that module; the MCP
adapter authenticates once per session and reuses the resolved identity.
Identity model¶
An identity is an agent_id string paired with an api_key. There is no
framework coupling — any HTTP client that can present a valid pair participates.
Two sources of valid pairs:
- Static keys — configured via the
ARTEL_AGENT_KEYSenv var (Settings.agent_keys). Format:
Settings.api_keys()builds{api_key: agent_id}.Settings.agent_projects()builds{agent_id: [projects]}. A third field that is empty or*means no project restriction (full visibility).-
Static identities are not rows in the
agentstable. -
Dynamic agents — rows in the
agentstable (id,api_key,role), created throughPOST /agents/register. Registration is gated byrequire_registration_key: the request must sendX-Registration-KeymatchingARTEL_REGISTRATION_KEY. IfARTEL_REGISTRATION_KEYis unset, registration is refused outright (no open enrollment).
_verify_agent(agent_id, api_key) checks static keys first, then falls back to
a agents table lookup. Either match authenticates.
Credential transports¶
require_agent accepts credentials three ways, checked in order:
- Bearer JWT —
Authorization: Bearer <token>. Tokens are HS256, issuerartel, with claimssub(agent_id) andkey(api_key). The signing secret is persisted in thekvtable underjwt_secret; it is auto-generated (secrets.token_hex(32)) on first use and stable thereafter, so tokens survive restarts but not a DB wipe. After decode the embedded(sub, key)pair is still run through_verify_agent— a validly signed token for a deleted/unknown agent is rejected. Mint withjwt_utils.sign_token(agent_id, api_key, ttl); default TTL isSettings.jwt_ttl= 2592000s (30 days). - Header pair —
X-Agent-Id+X-Api-Key. - Query pair — only on feed routes via
require_agent_feed:?agent_id=&api_key=. This exists so RSS/Atom readers that cannot set custom headers can still authenticate. Treat these URLs as bearer secrets. - UI session cookie —
require_agentchecks this first: if theX-Ui-Sessionheader is present it authenticates via thesessioncookie against theui_sessionstable (verify_ui_session) and, on success, resolves toSettings.ui_agent_id(the owner identity). No API key is involved. This is what the dashboard uses; the owner page no longer embeds a long-lived key (window._akey=""). Because access is bound to the liveui_sessionsrow, logout deletes the row and immediately revokes API access, even for a captured/cached page or replayed cookie.
The X-Ui-Session custom header gates this path for CSRF: a cross-site
page cannot set custom headers without a CORS preflight (none is granted),
and the cookie is SameSite=Lax, so a forged cross-site request can neither
ride the cookie on state-changing calls nor add the header. Stateless, no
schema migration. verify_ui_session("") returns True when
Settings.ui_password is unset (open instance = open owner UI), matching
the pre-redesign behavior.
Any successful authentication calls presence.update_seen(agent_id, ...) and
returns the resolved agent_id. Any failure raises 401 invalid credentials
(or 401 invalid or expired session for the UI-session path). A bad/expired
JWT never falls through to header auth — it 401s.
Roles (RBAC)¶
role_of(agent_id)readsagents.role. If there is no row, or the value is unrecognized, it returns"agent". Static-key identities therefore always resolve toagent— they cannot bearchivistorownerunless a matchingagentsrow exists with that role. (Known caveat: the archivist runs under a static key by default, so elevating it to thearchivistrole requires a DB row; without one it is treated as a plain agent for role checks.)is_owner(agent_id)→ role == owner.can_curate_memory(agent_id)→ role in {owner, archivist}. This is the gate for editing/curating memory entries the caller does not own.require_role(minimum)returns a FastAPI dependency that authenticates viarequire_agentthen enforcesROLE_RANK[role] >= ROLE_RANK[minimum], raising403 insufficient roleotherwise.
Dependency aliases¶
| Alias | Wraps | Use |
|---|---|---|
AgentDep |
require_agent |
authenticated, role not checked |
ReaderDep |
require_role("viewer") |
any authenticated caller (read) |
ActorDep |
require_role("agent") |
normal write operations |
OwnerDep |
require_role("owner") |
privileged/destructive operations |
Project scoping¶
Authorization for which rows a caller sees is separate from role.
_memberships(agent_id)returns:None→ unrestricted (sees everything). True forui_agent_id, and for static agents whose project field is*/empty.- otherwise the union of static-config projects and
project_membersrows. project_filter(agent_id)turns that into a SQLWHEREfragment:- unrestricted → no filter
- no memberships →
(project IS NULL)(only global rows) - else →
(project IS NULL OR project IN (...))
Routes that return collections apply project_filter; single-entry routes
re-check membership against the row's project and return 403 not a member of
this project on mismatch.
Caveats¶
- Query-param credentials on feed routes are full credentials in the URL — scope feed links accordingly; they are not read-only tokens.
- The JWT secret lives in the DB, not config. Resetting the DB invalidates all outstanding tokens.
- Role is DB-only. An owner whose access is via a static key still resolves to
agentforrequire_role; grant owner/archivist by inserting/marking anagentsrow. - Dashboard owner auth is session-bound, not key-bound (the redesign): the
page carries no credential, so the owner key cannot leak via a cached page,
devtools, or proxy, and logout is a true revocation. Programmatic owner
access (scripts, MCP) still uses the static/JWT key paths unchanged — only
the browser UI moved to the cookie+
X-Ui-Sessionpath.