Skip to main content

ai_crew_sync/store/
admin.rs

1//! Administration: teams, agents, agent tokens and administrative credentials.
2//!
3//! Two callers share this module and must behave identically: the operator
4//! CLI next to Postgres (`ai-crew-sync team|agent|token …`, [`Actor::Cli`])
5//! and the remote administration API (`/admin/*`, [`Actor::Admin`]). Every
6//! mutating operation takes the actor so the audit trail records who did what
7//! whichever door they came through, and takes the team as a resolved id so a
8//! caller that already checked its scope cannot be widened by a name.
9//!
10//! Secrets exist here for exactly one statement: the `INSERT` that stores
11//! their hash. They are returned to the caller once and never logged,
12//! audited, or published as an event.
13
14use sqlx::PgPool;
15use uuid::Uuid;
16
17use crate::{
18    auth::{ADMIN_TOKEN_PREFIX, generate_admin_token, generate_token, hash_token, token_prefix},
19    error::{BusError, BusResult},
20};
21
22/// Who performs an administrative action, for the audit trail.
23#[derive(Clone, Copy, Debug)]
24pub enum Actor {
25    /// The operator CLI with a database connection. No credential involved.
26    Cli,
27    /// A remote administrative credential.
28    Admin(Uuid),
29}
30
31impl Actor {
32    fn columns(self) -> (&'static str, Option<Uuid>) {
33        match self {
34            Actor::Cli => ("cli", None),
35            Actor::Admin(id) => ("http", Some(id)),
36        }
37    }
38}
39
40/// Identity resolved from an administrative credential. It names no agent:
41/// an administrator cannot post, claim or read anything on the bus.
42#[derive(Clone, Debug)]
43pub struct AdminCtx {
44    pub id: Uuid,
45    /// `None` is a global administrator. `Some` administers that team only.
46    pub team_id: Option<Uuid>,
47    pub team_slug: Option<String>,
48}
49
50impl AdminCtx {
51    pub fn is_global(&self) -> bool {
52        self.team_id.is_none()
53    }
54}
55
56#[derive(Clone, Debug, serde::Serialize)]
57pub struct TeamRow {
58    pub id: Uuid,
59    pub slug: String,
60    pub name: String,
61    pub agents: i64,
62}
63
64#[derive(Clone, Debug, serde::Serialize)]
65pub struct AgentRow {
66    pub id: Uuid,
67    pub name: String,
68    pub display_name: Option<String>,
69    pub disabled: bool,
70    pub active_tokens: i64,
71}
72
73/// A freshly minted agent token. `token` is the secret, present in this
74/// struct and nowhere else.
75#[derive(Clone, Debug, serde::Serialize)]
76pub struct IssuedToken {
77    pub id: Uuid,
78    pub token: String,
79    pub prefix: String,
80    pub agent: String,
81    pub team: String,
82    pub label: Option<String>,
83}
84
85#[derive(Clone, Debug, serde::Serialize)]
86pub struct TokenRow {
87    pub id: Uuid,
88    pub agent: String,
89    pub prefix: String,
90    pub label: Option<String>,
91    pub created_at: chrono::DateTime<chrono::Utc>,
92    pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
93    pub revoked: bool,
94}
95
96/// A freshly minted administrative credential. `token` is the secret.
97#[derive(Clone, Debug, serde::Serialize)]
98pub struct IssuedAdmin {
99    pub id: Uuid,
100    pub token: String,
101    pub prefix: String,
102    /// `None` for a global credential.
103    pub team: Option<String>,
104    pub label: Option<String>,
105}
106
107#[derive(Clone, Debug, serde::Serialize)]
108pub struct AdminRow {
109    pub id: Uuid,
110    /// `None` for a global credential.
111    pub team: Option<String>,
112    pub prefix: String,
113    pub label: Option<String>,
114    pub created_at: chrono::DateTime<chrono::Utc>,
115    pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
116    pub revoked: bool,
117}
118
119/// Active tokens one agent may hold. A ceiling on what a leaked or looping
120/// administrative credential can mint, and a nudge to revoke what is no
121/// longer used: one token per repository is the intended shape, not one per
122/// session start.
123pub const MAX_ACTIVE_TOKENS_PER_AGENT: i64 = 100;
124
125/// Longest name, slug or label accepted. These are identifiers people type,
126/// not documents.
127pub const MAX_NAME_BYTES: usize = 64;
128pub const MAX_LABEL_BYTES: usize = 128;
129
130fn check_name(field: &str, raw: &str) -> BusResult<String> {
131    let value = raw.trim().to_lowercase();
132    if value.is_empty() {
133        return Err(BusError::invalid(format!("{field} cannot be empty")));
134    }
135    if value.len() > MAX_NAME_BYTES {
136        return Err(BusError::invalid(format!(
137            "{field} is {} bytes; the limit is {MAX_NAME_BYTES}",
138            value.len()
139        )));
140    }
141    if !value
142        .chars()
143        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
144    {
145        return Err(BusError::invalid(format!(
146            "{field} may only contain ASCII letters, digits, '-', '_' and '.'"
147        )));
148    }
149    Ok(value)
150}
151
152/// Free text people read back: a label, a display name, a team name. Trimmed,
153/// bounded, and free of control characters — it ends up in terminals, in
154/// listings and in the audit log.
155fn check_display(field: &str, value: Option<String>) -> BusResult<Option<String>> {
156    let Some(value) = value else {
157        return Ok(None);
158    };
159    let value = value.trim().to_owned();
160    if value.is_empty() {
161        return Ok(None);
162    }
163    if value.len() > MAX_LABEL_BYTES {
164        return Err(BusError::invalid(format!(
165            "{field} is {} bytes; the limit is {MAX_LABEL_BYTES}",
166            value.len()
167        )));
168    }
169    if value.chars().any(char::is_control) {
170        return Err(BusError::invalid(format!(
171            "{field} must not contain control characters"
172        )));
173    }
174    Ok(Some(value))
175}
176
177fn check_label(label: Option<String>) -> BusResult<Option<String>> {
178    check_display("label", label)
179}
180
181/// One audit row, on the same transaction as the mutation it records: the
182/// two commit together or not at all, so a mutation can never outlive a lost
183/// audit write.
184async fn audit(
185    conn: &mut sqlx::PgConnection,
186    actor: Actor,
187    action: &str,
188    team_id: Option<Uuid>,
189    subject_id: Option<Uuid>,
190    detail: serde_json::Value,
191) -> BusResult<()> {
192    let (source, admin_id) = actor.columns();
193    sqlx::query(
194        "INSERT INTO admin_audit (actor_source, actor_admin_id, action, team_id, subject_id, detail)
195         VALUES ($1, $2, $3, $4, $5, $6)",
196    )
197    .bind(source)
198    .bind(admin_id)
199    .bind(action)
200    .bind(team_id)
201    .bind(subject_id)
202    .bind(detail)
203    .execute(conn)
204    .await?;
205    Ok(())
206}
207
208// ------------------------------------------------------------------ teams --
209
210pub async fn team_id_by_slug(pool: &PgPool, slug: &str) -> BusResult<Uuid> {
211    let slug = slug.trim().to_lowercase();
212    let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM teams WHERE slug = $1")
213        .bind(&slug)
214        .fetch_optional(pool)
215        .await?;
216    row.map(|r| r.0)
217        .ok_or_else(|| BusError::not_found(format!("no team with slug '{slug}'")))
218}
219
220/// Create a team, or return the existing one with that slug unchanged.
221pub async fn create_team(
222    pool: &PgPool,
223    actor: Actor,
224    slug: &str,
225    name: Option<String>,
226) -> BusResult<TeamRow> {
227    let slug = check_name("team slug", slug)?;
228    let name = check_display("team name", name)?.unwrap_or_else(|| slug.clone());
229    let mut tx = pool.begin().await?;
230    let created: Option<(Uuid,)> = sqlx::query_as(
231        "INSERT INTO teams (slug, name) VALUES ($1, $2)
232         ON CONFLICT (slug) DO NOTHING RETURNING id",
233    )
234    .bind(&slug)
235    .bind(&name)
236    .fetch_optional(&mut *tx)
237    .await?;
238    if let Some((id,)) = created {
239        audit(
240            &mut tx,
241            actor,
242            "team.create",
243            Some(id),
244            Some(id),
245            serde_json::json!({ "slug": slug, "name": name }),
246        )
247        .await?;
248    }
249    tx.commit().await?;
250    let id = team_id_by_slug(pool, &slug).await?;
251    team_by_id(pool, id).await
252}
253
254/// One team as the listings show it.
255pub async fn team_by_id(pool: &PgPool, id: Uuid) -> BusResult<TeamRow> {
256    let row: Option<(String, String, i64)> = sqlx::query_as(
257        "SELECT t.slug, t.name, (SELECT count(*) FROM agents a WHERE a.team_id = t.id)
258         FROM teams t WHERE t.id = $1",
259    )
260    .bind(id)
261    .fetch_optional(pool)
262    .await?;
263    let Some((slug, name, agents)) = row else {
264        return Err(BusError::not_found("no such team"));
265    };
266    Ok(TeamRow {
267        id,
268        slug,
269        name,
270        agents,
271    })
272}
273
274/// Turn a team's conversation capability on or off. Off is the default and
275/// the safe state: installing a release must never expose a new surface.
276pub async fn set_conversations(
277    pool: &PgPool,
278    actor: Actor,
279    team_id: Uuid,
280    enabled: bool,
281) -> BusResult<()> {
282    let mut tx = pool.begin().await?;
283    let changed: Option<(String,)> = sqlx::query_as(
284        "UPDATE teams SET conversations_enabled = $2
285          WHERE id = $1 AND conversations_enabled <> $2 RETURNING slug",
286    )
287    .bind(team_id)
288    .bind(enabled)
289    .fetch_optional(&mut *tx)
290    .await?;
291    if let Some((slug,)) = changed {
292        audit(
293            &mut tx,
294            actor,
295            "team.capability",
296            Some(team_id),
297            Some(team_id),
298            serde_json::json!({ "slug": slug, "conversations_enabled": enabled }),
299        )
300        .await?;
301    }
302    tx.commit().await?;
303    Ok(())
304}
305
306/// Route a team's **new** conversations to a backend.
307///
308/// Existing threads are not migrated and never will be by this call: their
309/// bodies are where they are, and `conversations.backend` keeps saying so.
310/// Refused while the team has publications in flight, because switching
311/// away from a backend with work still queued for it leaves messages nobody
312/// drains.
313pub async fn set_default_backend(
314    pool: &PgPool,
315    actor: Actor,
316    team_id: Uuid,
317    backend: &str,
318) -> BusResult<()> {
319    if !matches!(backend, "postgres" | "jetstream") {
320        return Err(crate::error::BusError::invalid(
321            "backend must be 'postgres' or 'jetstream'",
322        ));
323    }
324    let (inflight,): (i64,) = sqlx::query_as(
325        "SELECT count(*) FROM conversation_outbox WHERE team_id = $1 AND state <> 'failed'",
326    )
327    .bind(team_id)
328    .fetch_one(pool)
329    .await?;
330    if inflight > 0 {
331        return Err(crate::error::BusError::conflict(format!(
332            "{inflight} message(s) of this team are still awaiting publication. Let them \
333             settle before changing the route; `team usage` shows when the queue is empty."
334        )));
335    }
336    let mut tx = pool.begin().await?;
337    let changed: Option<(String,)> = sqlx::query_as(
338        "UPDATE teams SET default_backend = $2
339          WHERE id = $1 AND default_backend <> $2 RETURNING slug",
340    )
341    .bind(team_id)
342    .bind(backend)
343    .fetch_optional(&mut *tx)
344    .await?;
345    if let Some((slug,)) = changed {
346        audit(
347            &mut tx,
348            actor,
349            "team.backend",
350            Some(team_id),
351            Some(team_id),
352            serde_json::json!({ "slug": slug, "default_backend": backend }),
353        )
354        .await?;
355    }
356    tx.commit().await?;
357    Ok(())
358}
359
360pub async fn list_teams(pool: &PgPool) -> BusResult<Vec<TeamRow>> {
361    let rows: Vec<(Uuid, String, String, i64)> = sqlx::query_as(
362        "SELECT t.id, t.slug, t.name, (SELECT count(*) FROM agents a WHERE a.team_id = t.id)
363         FROM teams t ORDER BY t.slug",
364    )
365    .fetch_all(pool)
366    .await?;
367    Ok(rows
368        .into_iter()
369        .map(|(id, slug, name, agents)| TeamRow {
370            id,
371            slug,
372            name,
373            agents,
374        })
375        .collect())
376}
377
378// ----------------------------------------------------------------- agents --
379
380/// Create an agent, or re-enable an existing one of that name. A repeated
381/// `create` is how an operator brings back a disabled teammate, so it is not
382/// an error. Audited as `agent.create` on creation and `agent.enable` on a
383/// re-enable; a repeat on an active agent changes nothing and logs nothing
384/// (a display-name tweak is cosmetic, not a security event).
385pub async fn create_agent(
386    pool: &PgPool,
387    actor: Actor,
388    team_id: Uuid,
389    name: &str,
390    display_name: Option<String>,
391) -> BusResult<AgentRow> {
392    let name = check_name("agent name", name)?;
393    let display_name = check_display("display name", display_name)?;
394    let mut tx = pool.begin().await?;
395    // Locked so two concurrent creates of the same name serialise on the
396    // row (the unique index serialises the inserts themselves).
397    let existing: Option<(Uuid, bool)> = sqlx::query_as(
398        "SELECT id, (disabled_at IS NOT NULL) FROM agents
399         WHERE team_id = $1 AND name = $2 FOR UPDATE",
400    )
401    .bind(team_id)
402    .bind(&name)
403    .fetch_optional(&mut *tx)
404    .await?;
405    let id = match existing {
406        None => {
407            let (id,): (Uuid,) = sqlx::query_as(
408                "INSERT INTO agents (team_id, name, display_name) VALUES ($1, $2, $3)
409                 RETURNING id",
410            )
411            .bind(team_id)
412            .bind(&name)
413            .bind(&display_name)
414            .fetch_one(&mut *tx)
415            .await?;
416            audit(
417                &mut tx,
418                actor,
419                "agent.create",
420                Some(team_id),
421                Some(id),
422                serde_json::json!({ "name": name, "display_name": display_name }),
423            )
424            .await?;
425            id
426        }
427        Some((id, disabled)) => {
428            sqlx::query(
429                "UPDATE agents SET display_name = COALESCE($2, display_name), disabled_at = NULL
430                 WHERE id = $1",
431            )
432            .bind(id)
433            .bind(&display_name)
434            .execute(&mut *tx)
435            .await?;
436            if disabled {
437                audit(
438                    &mut tx,
439                    actor,
440                    "agent.enable",
441                    Some(team_id),
442                    Some(id),
443                    serde_json::json!({ "name": name }),
444                )
445                .await?;
446            }
447            id
448        }
449    };
450    tx.commit().await?;
451    let rows = list_agents(pool, team_id).await?;
452    rows.into_iter()
453        .find(|a| a.id == id)
454        .ok_or_else(|| BusError::not_found("agent vanished after creation"))
455}
456
457pub async fn list_agents(pool: &PgPool, team_id: Uuid) -> BusResult<Vec<AgentRow>> {
458    let rows: Vec<(Uuid, String, Option<String>, bool, i64)> = sqlx::query_as(
459        r#"
460        SELECT a.id, a.name, a.display_name,
461               (a.disabled_at IS NOT NULL) AS disabled,
462               (SELECT count(*) FROM api_tokens t
463                 WHERE t.agent_id = a.id AND t.revoked_at IS NULL)
464        FROM agents a WHERE a.team_id = $1 ORDER BY a.name
465        "#,
466    )
467    .bind(team_id)
468    .fetch_all(pool)
469    .await?;
470    Ok(rows
471        .into_iter()
472        .map(
473            |(id, name, display_name, disabled, active_tokens)| AgentRow {
474                id,
475                name,
476                display_name,
477                disabled,
478                active_tokens,
479            },
480        )
481        .collect())
482}
483
484pub async fn disable_agent(
485    pool: &PgPool,
486    actor: Actor,
487    team_id: Uuid,
488    name: &str,
489) -> BusResult<()> {
490    let name = name.trim().to_lowercase();
491    let mut tx = pool.begin().await?;
492    // Only a real transition is audited; disabling twice is a quiet no-op.
493    let row: Option<(Uuid,)> = sqlx::query_as(
494        "UPDATE agents SET disabled_at = now()
495         WHERE team_id = $1 AND name = $2 AND disabled_at IS NULL RETURNING id",
496    )
497    .bind(team_id)
498    .bind(&name)
499    .fetch_optional(&mut *tx)
500    .await?;
501    match row {
502        Some((id,)) => {
503            audit(
504                &mut tx,
505                actor,
506                "agent.disable",
507                Some(team_id),
508                Some(id),
509                serde_json::json!({ "name": name }),
510            )
511            .await?;
512        }
513        None => {
514            let exists: Option<(Uuid,)> =
515                sqlx::query_as("SELECT id FROM agents WHERE team_id = $1 AND name = $2")
516                    .bind(team_id)
517                    .bind(&name)
518                    .fetch_optional(&mut *tx)
519                    .await?;
520            if exists.is_none() {
521                return Err(BusError::not_found(format!(
522                    "no agent '{name}' in this team"
523                )));
524            }
525        }
526    }
527    tx.commit().await?;
528    Ok(())
529}
530
531// ----------------------------------------------------------- agent tokens --
532
533/// Mint a token for `agent` in `team_id`. The token belongs to exactly that
534/// agent and team; the label is a display hint and plays no part in identity.
535pub async fn issue_token(
536    pool: &PgPool,
537    actor: Actor,
538    team_id: Uuid,
539    agent: &str,
540    label: Option<String>,
541) -> BusResult<IssuedToken> {
542    let agent = agent.trim().to_lowercase();
543    let label = check_label(label)?;
544    let mut tx = pool.begin().await?;
545    // The agent row is locked for the rest of the transaction, so the
546    // active-token count below cannot be raced past the cap by a concurrent
547    // issue for the same agent.
548    let row: Option<(Uuid, String, bool)> = sqlx::query_as(
549        "SELECT a.id, t.slug, (a.disabled_at IS NOT NULL)
550         FROM agents a JOIN teams t ON t.id = a.team_id
551         WHERE a.team_id = $1 AND a.name = $2 FOR UPDATE OF a",
552    )
553    .bind(team_id)
554    .bind(&agent)
555    .fetch_optional(&mut *tx)
556    .await?;
557    let Some((agent_id, team_slug, disabled)) = row else {
558        return Err(BusError::not_found(format!(
559            "no agent '{agent}' in this team — create it first"
560        )));
561    };
562    if disabled {
563        return Err(BusError::conflict(format!(
564            "agent '{agent}' is disabled; re-create it to enable it before issuing a token"
565        )));
566    }
567    let (active,): (i64,) = sqlx::query_as(
568        "SELECT count(*) FROM api_tokens WHERE agent_id = $1 AND revoked_at IS NULL",
569    )
570    .bind(agent_id)
571    .fetch_one(&mut *tx)
572    .await?;
573    if active >= MAX_ACTIVE_TOKENS_PER_AGENT {
574        return Err(BusError::conflict(format!(
575            "agent '{agent}' already has {active} active tokens; the limit is \
576             {MAX_ACTIVE_TOKENS_PER_AGENT}. Revoke the ones no longer in use first"
577        )));
578    }
579
580    let (_, issued_by) = actor.columns();
581    let raw = generate_token();
582    let prefix = token_prefix(&raw);
583    let (id,): (Uuid,) = sqlx::query_as(
584        "INSERT INTO api_tokens (agent_id, token_hash, prefix, label, issued_by_admin)
585         VALUES ($1, $2, $3, $4, $5) RETURNING id",
586    )
587    .bind(agent_id)
588    .bind(hash_token(&raw))
589    .bind(&prefix)
590    .bind(&label)
591    .bind(issued_by)
592    .fetch_one(&mut *tx)
593    .await?;
594    audit(
595        &mut tx,
596        actor,
597        "token.issue",
598        Some(team_id),
599        Some(id),
600        serde_json::json!({ "agent": agent, "label": label, "prefix": prefix }),
601    )
602    .await?;
603    tx.commit().await?;
604    Ok(IssuedToken {
605        id,
606        token: raw,
607        prefix,
608        agent,
609        team: team_slug,
610        label,
611    })
612}
613
614pub async fn list_tokens(pool: &PgPool, team_id: Uuid) -> BusResult<Vec<TokenRow>> {
615    let rows: Vec<(
616        Uuid,
617        String,
618        String,
619        Option<String>,
620        chrono::DateTime<chrono::Utc>,
621        Option<chrono::DateTime<chrono::Utc>>,
622        bool,
623    )> = sqlx::query_as(
624        r#"
625        SELECT t.id, a.name, t.prefix, t.label, t.created_at, t.last_used_at,
626               (t.revoked_at IS NOT NULL) AS revoked
627        FROM api_tokens t
628        JOIN agents a ON a.id = t.agent_id
629        WHERE a.team_id = $1
630        ORDER BY a.name, t.created_at
631        "#,
632    )
633    .bind(team_id)
634    .fetch_all(pool)
635    .await?;
636    Ok(rows
637        .into_iter()
638        .map(
639            |(id, agent, prefix, label, created_at, last_used_at, revoked)| TokenRow {
640                id,
641                agent,
642                prefix,
643                label,
644                created_at,
645                last_used_at,
646                revoked,
647            },
648        )
649        .collect())
650}
651
652/// Revoke an agent token. With `team_id` set, a token outside that team is
653/// reported as not found — a team administrator learns nothing about other
654/// teams' ids. Revoking an already revoked token is a no-op that succeeds
655/// and logs nothing: the UPDATE is conditional on the token being active, so
656/// two concurrent revocations produce exactly one transition and one row.
657pub async fn revoke_token(
658    pool: &PgPool,
659    actor: Actor,
660    team_id: Option<Uuid>,
661    id: Uuid,
662) -> BusResult<()> {
663    let mut tx = pool.begin().await?;
664    // The agent row first: registration, resume and recovery serialise on
665    // it, so a window registering under this token right now either lands
666    // before the revocation and is swept below, or waits and finds the
667    // token gone. Without the lock a session could slip in between.
668    //
669    // NO KEY UPDATE, not UPDATE: a first heartbeat holds the session row
670    // (the epoch guard) and then inserts presence, whose foreign key takes
671    // a key share on this agent. FOR UPDATE blocks that share while this
672    // transaction waits on the session row, and Postgres breaks the cycle
673    // by rolling the revocation back. NO KEY UPDATE serialises the
674    // lifecycle paths with each other and lets the key share through.
675    sqlx::query(
676        "SELECT a.id FROM agents a JOIN api_tokens t ON t.agent_id = a.id
677          WHERE t.id = $1 AND ($2::uuid IS NULL OR a.team_id = $2)
678          FOR NO KEY UPDATE OF a",
679    )
680    .bind(id)
681    .bind(team_id)
682    .execute(&mut *tx)
683    .await?;
684    let revoked: Option<(Uuid, String, String)> = sqlx::query_as(
685        "UPDATE api_tokens t SET revoked_at = now()
686         FROM agents a
687         WHERE t.id = $1 AND t.agent_id = a.id AND t.revoked_at IS NULL
688           AND ($2::uuid IS NULL OR a.team_id = $2)
689         RETURNING a.team_id, a.name, t.prefix",
690    )
691    .bind(id)
692    .bind(team_id)
693    .fetch_optional(&mut *tx)
694    .await?;
695    match revoked {
696        Some((owner_team, agent, prefix)) => {
697            // A session credential is a child of this token: authentication
698            // already refuses it once the parent is gone, and the row must
699            // say the same, or recovery keeps counting a window that cannot
700            // answer and its label stays reserved for a credential that no
701            // longer works.
702            let sessions: Vec<(String,)> = sqlx::query_as(
703                "UPDATE agent_sessions SET revoked_at = now()
704                  WHERE parent_token = $1 AND revoked_at IS NULL
705                  RETURNING label",
706            )
707            .bind(id)
708            .fetch_all(&mut *tx)
709            .await?;
710            audit(
711                &mut tx,
712                actor,
713                "token.revoke",
714                Some(owner_team),
715                Some(id),
716                serde_json::json!({
717                    "agent": agent,
718                    "prefix": prefix,
719                    "sessions_revoked": sessions.iter().map(|s| &s.0).collect::<Vec<_>>(),
720                }),
721            )
722            .await?;
723        }
724        None => {
725            let exists: Option<(Uuid,)> = sqlx::query_as(
726                "SELECT t.id FROM api_tokens t JOIN agents a ON a.id = t.agent_id
727                 WHERE t.id = $1 AND ($2::uuid IS NULL OR a.team_id = $2)",
728            )
729            .bind(id)
730            .bind(team_id)
731            .fetch_optional(&mut *tx)
732            .await?;
733            if exists.is_none() {
734                return Err(BusError::not_found(format!("no token with id {id}")));
735            }
736        }
737    }
738    tx.commit().await?;
739    Ok(())
740}
741
742// ------------------------------------------------- administrative credentials --
743
744/// Mint an administrative credential: for one team, or global when `team_id`
745/// is `None`. Only the local CLI (bootstrap) and a global administrator may
746/// call this; the caller enforces that, this function records it.
747pub async fn grant_admin(
748    pool: &PgPool,
749    actor: Actor,
750    team_id: Option<Uuid>,
751    label: Option<String>,
752) -> BusResult<IssuedAdmin> {
753    let label = check_label(label)?;
754    let team_slug = match team_id {
755        Some(tid) => {
756            let row: Option<(String,)> = sqlx::query_as("SELECT slug FROM teams WHERE id = $1")
757                .bind(tid)
758                .fetch_optional(pool)
759                .await?;
760            let Some((slug,)) = row else {
761                return Err(BusError::not_found("no such team"));
762            };
763            Some(slug)
764        }
765        None => None,
766    };
767    let (_, issued_by) = actor.columns();
768    let raw = generate_admin_token();
769    let prefix = token_prefix(&raw);
770    let mut tx = pool.begin().await?;
771    let (id,): (Uuid,) = sqlx::query_as(
772        "INSERT INTO admin_tokens (team_id, token_hash, prefix, label, issued_by)
773         VALUES ($1, $2, $3, $4, $5) RETURNING id",
774    )
775    .bind(team_id)
776    .bind(hash_token(&raw))
777    .bind(&prefix)
778    .bind(&label)
779    .bind(issued_by)
780    .fetch_one(&mut *tx)
781    .await?;
782    audit(
783        &mut tx,
784        actor,
785        "admin.grant",
786        team_id,
787        Some(id),
788        serde_json::json!({
789            "scope": if team_id.is_some() { "team" } else { "global" },
790            "label": label,
791            "prefix": prefix,
792        }),
793    )
794    .await?;
795    tx.commit().await?;
796    Ok(IssuedAdmin {
797        id,
798        token: raw,
799        prefix,
800        team: team_slug,
801        label,
802    })
803}
804
805/// List administrative credentials. `team_id` `None` lists every credential
806/// (global ones included); `Some` lists that team's only.
807pub async fn list_admins(pool: &PgPool, team_id: Option<Uuid>) -> BusResult<Vec<AdminRow>> {
808    let rows: Vec<(
809        Uuid,
810        Option<String>,
811        String,
812        Option<String>,
813        chrono::DateTime<chrono::Utc>,
814        Option<chrono::DateTime<chrono::Utc>>,
815        bool,
816    )> = sqlx::query_as(
817        r#"
818        SELECT c.id, t.slug, c.prefix, c.label, c.created_at, c.last_used_at,
819               (c.revoked_at IS NOT NULL) AS revoked
820        FROM admin_tokens c
821        LEFT JOIN teams t ON t.id = c.team_id
822        WHERE ($1::uuid IS NULL OR c.team_id = $1)
823        ORDER BY t.slug NULLS FIRST, c.created_at
824        "#,
825    )
826    .bind(team_id)
827    .fetch_all(pool)
828    .await?;
829    Ok(rows
830        .into_iter()
831        .map(
832            |(id, team, prefix, label, created_at, last_used_at, revoked)| AdminRow {
833                id,
834                team,
835                prefix,
836                label,
837                created_at,
838                last_used_at,
839                revoked,
840            },
841        )
842        .collect())
843}
844
845/// Revoke an administrative credential. With `scope` set, a credential that
846/// is global or belongs to another team is reported as not found. Same
847/// atomic shape as [`revoke_token`]: one transition, one audit row.
848pub async fn revoke_admin(
849    pool: &PgPool,
850    actor: Actor,
851    scope: Option<Uuid>,
852    id: Uuid,
853) -> BusResult<()> {
854    let mut tx = pool.begin().await?;
855    let revoked: Option<(Option<Uuid>, String)> = sqlx::query_as(
856        "UPDATE admin_tokens SET revoked_at = now()
857         WHERE id = $1 AND revoked_at IS NULL AND ($2::uuid IS NULL OR team_id = $2)
858         RETURNING team_id, prefix",
859    )
860    .bind(id)
861    .bind(scope)
862    .fetch_optional(&mut *tx)
863    .await?;
864    match revoked {
865        Some((team_id, prefix)) => {
866            audit(
867                &mut tx,
868                actor,
869                "admin.revoke",
870                team_id,
871                Some(id),
872                serde_json::json!({ "prefix": prefix }),
873            )
874            .await?;
875        }
876        None => {
877            let exists: Option<(Uuid,)> = sqlx::query_as(
878                "SELECT id FROM admin_tokens
879                 WHERE id = $1 AND ($2::uuid IS NULL OR team_id = $2)",
880            )
881            .bind(id)
882            .bind(scope)
883            .fetch_optional(&mut *tx)
884            .await?;
885            if exists.is_none() {
886                return Err(BusError::not_found(format!(
887                    "no administrative credential with id {id}"
888                )));
889            }
890        }
891    }
892    tx.commit().await?;
893    Ok(())
894}
895
896/// Resolve an administrative credential. `Ok(None)` is "not a valid, active
897/// credential" — the caller turns that into 401 without saying which.
898pub async fn resolve_admin(pool: &PgPool, raw: &str) -> BusResult<Option<AdminCtx>> {
899    let raw = raw.trim();
900    if !raw.starts_with(ADMIN_TOKEN_PREFIX) {
901        return Ok(None);
902    }
903    let row: Option<(Uuid, Option<Uuid>, Option<String>)> = sqlx::query_as(
904        "SELECT c.id, c.team_id, t.slug
905         FROM admin_tokens c LEFT JOIN teams t ON t.id = c.team_id
906         WHERE c.token_hash = $1 AND c.revoked_at IS NULL",
907    )
908    .bind(hash_token(raw))
909    .fetch_optional(pool)
910    .await?;
911    let Some((id, team_id, team_slug)) = row else {
912        return Ok(None);
913    };
914    // Best-effort, like agent tokens: usage bookkeeping must not fail a request.
915    let _ = sqlx::query("UPDATE admin_tokens SET last_used_at = now() WHERE id = $1")
916        .bind(id)
917        .execute(pool)
918        .await;
919    Ok(Some(AdminCtx {
920        id,
921        team_id,
922        team_slug,
923    }))
924}
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929
930    #[test]
931    fn names_are_normalised_and_bounded() {
932        assert_eq!(check_name("agent name", "  Backend ").unwrap(), "backend");
933        assert!(check_name("agent name", "").is_err());
934        assert!(check_name("agent name", "with space").is_err());
935        assert!(check_name("agent name", "a/b").is_err());
936        assert!(check_name("agent name", &"x".repeat(MAX_NAME_BYTES + 1)).is_err());
937    }
938
939    #[test]
940    fn labels_are_optional_and_bounded() {
941        assert_eq!(check_label(None).unwrap(), None);
942        assert_eq!(check_label(Some("  ".into())).unwrap(), None);
943        assert_eq!(
944            check_label(Some(" sesion backend ".into()))
945                .unwrap()
946                .as_deref(),
947            Some("sesion backend")
948        );
949        assert!(check_label(Some("x".repeat(MAX_LABEL_BYTES + 1))).is_err());
950        assert!(check_label(Some("a\nb".into())).is_err());
951        assert!(check_display("team name", Some("Acme\x1b[31m".into())).is_err());
952    }
953}