diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8facb535..8b08e9bd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,11 @@ cargo test The whole suite runs in seconds. Every new MCP tool and REST endpoint ships with tests. Conventions: - All tests use in-memory SQLite via `crate::db::open_memory()`. +- **Exception:** full-command tests of `lific init` (_`cmd_init`_) exercise the + real filesystem and a genuine on-disk DB, because init writes a config file + and opens the DB from a path — it cannot run against an in-memory database. + These use a self-cleaning temp dir (`TempDir` in `init_target_tests`) and + stay out of the repo tree. - MCP tool tests call methods directly via `Parameters(...)` on a `LificMcp` instance. - REST API tests use `tower::ServiceExt::oneshot` against the axum router. - Test names describe behavior, not implementation. diff --git a/migrations/036_oauth_client_tool.sql b/migrations/036_oauth_client_tool.sql new file mode 100644 index 00000000..60f75365 --- /dev/null +++ b/migrations/036_oauth_client_tool.sql @@ -0,0 +1,8 @@ +-- LIFIC-15: remember which tool a registered OAuth client is, so a reconnect +-- pre-fills (rather than re-asks) the approval pick-list. +-- +-- `client_id` comes from DCR (minted once, reused across reconnects), so the +-- tool choice is a stable attribute of the persistent client, not something to +-- re-derive on every visit. NULL for clients registered before this migration +-- and for clients that have never been approved (no tool chosen yet). +ALTER TABLE oauth_clients ADD COLUMN tool_id TEXT; \ No newline at end of file diff --git a/migrations/037_users_tool_id.sql b/migrations/037_users_tool_id.sql new file mode 100644 index 00000000..96ce9508 --- /dev/null +++ b/migrations/037_users_tool_id.sql @@ -0,0 +1,8 @@ +-- LIFIC-17: stable agent identity. +-- +-- A connected agent is deduplicated on (owner_id, tool_id), NOT the derived +-- `{tool}-{owner.username}` string. Renaming the owner changes the string but +-- not the pair, so the agent must survive a rename. NULL for human users and +-- for bots minted before this migration (legacy bots are backfilled lazily on +-- their next connect). +ALTER TABLE users ADD COLUMN tool_id TEXT; \ No newline at end of file diff --git a/src/api/activity.rs b/src/api/activity.rs index 5f9169fb..76fa33f4 100644 --- a/src/api/activity.rs +++ b/src/api/activity.rs @@ -10,7 +10,7 @@ use crate::authz; use crate::db::queries::activity::{ActivityScope, actor_stats, list_activity}; use crate::db::{ DbPool, - models::{ActivityFeed, ActorStat, AuthUser, Role}, + models::{ActivityFeed, ActorStat, Role}, }; use crate::error::LificError; @@ -22,7 +22,7 @@ use super::with_read; /// (`project_id = None`) fall back to admin-only. async fn require_scope_viewer( db: &DbPool, - auth_user: &Option, + identity: &Option, scope: &ActivityScope, ) -> Result<(), LificError> { let project_id: Option = match *scope { @@ -38,8 +38,8 @@ async fn require_scope_viewer( ActivityScope::Project(id) => Some(id), }; match project_id { - Some(pid) => authz::require_role(db, auth_user, pid, Role::Viewer), - None => authz::require_workspace_admin(db, auth_user), + Some(pid) => authz::require_role(db, identity, pid, Role::Viewer), + None => authz::require_workspace_admin(db, identity), } } @@ -53,24 +53,24 @@ pub(super) struct ActivityQuery { /// comments, label attach/detach, and relation link/unlink events. pub(super) async fn issue_activity( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, Query(q): Query, ) -> Result, LificError> { let scope = ActivityScope::Issue(id); - require_scope_viewer(&db, &auth_user, &scope).await?; + require_scope_viewer(&db, &identity, &scope).await?; with_read(&db, |conn| list_activity(conn, scope, q.limit, q.offset)).map(Json) } /// GET /api/pages/{id}/activity pub(super) async fn page_activity( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, Query(q): Query, ) -> Result, LificError> { let scope = ActivityScope::Page(id); - require_scope_viewer(&db, &auth_user, &scope).await?; + require_scope_viewer(&db, &identity, &scope).await?; with_read(&db, |conn| list_activity(conn, scope, q.limit, q.offset)).map(Json) } @@ -78,12 +78,12 @@ pub(super) async fn page_activity( /// create/edit/done/move/delete and the issue-driven cascade rows. pub(super) async fn plan_activity( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, Query(q): Query, ) -> Result, LificError> { let scope = ActivityScope::Plan(id); - require_scope_viewer(&db, &auth_user, &scope).await?; + require_scope_viewer(&db, &identity, &scope).await?; with_read(&db, |conn| list_activity(conn, scope, q.limit, q.offset)).map(Json) } @@ -91,12 +91,12 @@ pub(super) async fn plan_activity( /// first: issues, pages, comments, modules, labels, folders. pub(super) async fn project_activity( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, Query(q): Query, ) -> Result, LificError> { let scope = ActivityScope::Project(id); - require_scope_viewer(&db, &auth_user, &scope).await?; + require_scope_viewer(&db, &identity, &scope).await?; with_read(&db, |conn| list_activity(conn, scope, q.limit, q.offset)).map(Json) } @@ -104,10 +104,10 @@ pub(super) async fn project_activity( /// active first (LIF-158: actor rail + expanded-entry stats). pub(super) async fn project_activity_actors( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, ) -> Result>, LificError> { - authz::require_role(&db, &auth_user, id, Role::Viewer)?; + authz::require_role(&db, &identity, id, Role::Viewer)?; with_read(&db, |conn| actor_stats(conn, id)).map(Json) } diff --git a/src/api/attachments.rs b/src/api/attachments.rs index d25b361f..3c3c9022 100644 --- a/src/api/attachments.rs +++ b/src/api/attachments.rs @@ -72,15 +72,16 @@ pub struct UploadResponse { /// unlinked until the entity's markdown is saved and re-scanned. pub(super) async fn upload_attachment( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Extension(realtime): Extension, Extension(store): Extension, Extension(config): Extension, Extension(limiter): Extension>, mut multipart: Multipart, ) -> Result { - let user = auth_user - .clone() + let user = identity + .as_ref() + .map(|i| i.user.clone()) .ok_or_else(|| LificError::Forbidden("authentication required to upload".into()))?; // Per-user rate limit (mirrors the signup/login limiter pattern). @@ -194,7 +195,7 @@ pub(super) struct ListForEntityQuery { /// reading the entity itself). pub(super) async fn list_entity_attachments( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Query(query): Query, ) -> Result>, LificError> { let entity: AttachmentEntity = query.entity_type.parse().map_err(LificError::BadRequest)?; @@ -203,8 +204,8 @@ pub(super) async fn list_entity_attachments( // pages (no project) fall back to workspace-admin. let project_id = resolve_entity_project(&db, entity, query.entity_id)?; match project_id { - Some(pid) => authz::require_role(&db, &auth_user, pid, Role::Viewer)?, - None => authz::require_workspace_admin(&db, &auth_user)?, + Some(pid) => authz::require_role(&db, &identity, pid, Role::Viewer)?, + None => authz::require_workspace_admin(&db, &identity)?, } let items = with_read(&db, |conn| { @@ -243,7 +244,7 @@ fn resolve_entity_project( /// addressed, so the response is immutable-cacheable forever. pub(super) async fn download_attachment( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Extension(store): Extension, Path(id): Path, ) -> Result { @@ -252,7 +253,7 @@ pub(super) async fn download_attachment( // Authorize: the caller must be able to view SOME project this attachment // is linked into (Viewer), or be the uploader / an admin for a still- // unlinked attachment. - authorize_read(&db, &auth_user, &attachment)?; + authorize_read(&db, &identity, &attachment)?; let bytes = store.read(&attachment.sha256)?; let is_image = storage::is_image_mime(&attachment.mime); @@ -290,17 +291,18 @@ pub(super) async fn download_attachment( /// the sidecar file if no other row shares the content hash. pub(super) async fn delete_attachment( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Extension(realtime): Extension, Extension(store): Extension, Path(id): Path, ) -> Result, LificError> { - let user = auth_user - .clone() + let user = identity + .as_ref() + .map(|i| i.user.clone()) .ok_or_else(|| LificError::Forbidden("authentication required".into()))?; let attachment = with_read(&db, |conn| q::get_attachment(conn, id))?; - authorize_delete(&db, &auth_user, &user, &attachment)?; + authorize_delete(&db, &identity, &user, &attachment)?; let events = with_write(&db, |conn| { let events = linked_attachment_events(conn, id)?; @@ -458,7 +460,7 @@ fn owning_project_ids( /// behavior — matching every other GET while the flag is off. fn authorize_read( db: &DbPool, - auth_user: &Option, + identity: &Option, attachment: &Attachment, ) -> Result<(), LificError> { let project_ids = with_read(db, |conn| owning_project_ids(conn, attachment.id))?; @@ -467,7 +469,7 @@ fn authorize_read( // Unlinked: only the uploader or an admin can read it. (When // enforcement is off we still restrict unlinked reads to the uploader // to avoid an enumeration hole on freshly-uploaded blobs.) - match auth_user { + match identity.as_ref().map(|i| &i.user) { Some(u) if u.is_admin => Ok(()), Some(u) if Some(u.id) == attachment.uploader_id => Ok(()), _ => Err(LificError::Forbidden( @@ -478,7 +480,7 @@ fn authorize_read( // Viewer on ANY linked project is enough to read. let mut last_err = None; for pid in project_ids { - match authz::require_role(db, auth_user, pid, Role::Viewer) { + match authz::require_role(db, identity, pid, Role::Viewer) { Ok(()) => return Ok(()), Err(e) => last_err = Some(e), } @@ -492,7 +494,7 @@ fn authorize_read( /// Delete gate: uploader, admin, or Maintainer on any owning project. fn authorize_delete( db: &DbPool, - auth_user: &Option, + identity: &Option, user: &AuthUser, attachment: &Attachment, ) -> Result<(), LificError> { @@ -501,7 +503,7 @@ fn authorize_delete( } let project_ids = with_read(db, |conn| owning_project_ids(conn, attachment.id))?; for pid in project_ids { - if authz::require_role(db, auth_user, pid, Role::Maintainer).is_ok() { + if authz::require_role(db, identity, pid, Role::Maintainer).is_ok() { return Ok(()); } } @@ -811,6 +813,24 @@ mod api_tests { username: "a".into(), display_name: "A".into(), is_admin: true, + }))) + .layer(Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: admin_id, + username: "a".into(), + display_name: "A".into(), + is_admin: true, + }, + transport: crate::actor::Transport::Web, + }))) + .layer(Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: admin_id, + username: "a".into(), + display_name: "A".into(), + is_admin: true, + }, + transport: crate::actor::Transport::Web, }))); let resp = upload(&app, "big.png", "image/png", &png_bytes(), None).await; @@ -847,6 +867,15 @@ mod api_tests { username: lead.username.clone(), display_name: lead.display_name.clone(), is_admin: false, + }))) + .layer(axum::Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: lead.id, + username: lead.username.clone(), + display_name: lead.display_name.clone(), + is_admin: false, + }, + transport: crate::actor::Transport::Web, }))); let issue = parse_json( @@ -1002,6 +1031,15 @@ mod api_tests { username: "gc".into(), display_name: "GC".into(), is_admin: true, + }))) + .layer(axum::Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: admin_id, + username: "gc".into(), + display_name: "GC".into(), + is_admin: true, + }, + transport: crate::actor::Transport::Web, }))); let (project_id2, _) = seed_project(&app).await; diff --git a/src/api/auth.rs b/src/api/auth.rs index 10991f80..d50818eb 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -254,8 +254,16 @@ pub(super) async fn auth_auto_login( )); } - let admin = crate::db::queries::users::first_admin(&conn)? - .ok_or_else(|| LificError::BadRequest("no admin account exists to sign in as".into()))?; + // LIFIC-8: the "no credential → first admin" fallback is consolidated in + // `resolve_caller`. Auto-login has no credential (it is the thing that + // *produces* a session), so the passwordless fallback applies. + let admin = crate::resolve_caller::resolve_caller_conn( + &conn, + None, + crate::actor::Transport::Web, + )? + .ok_or_else(|| LificError::BadRequest("no admin account exists to sign in as".into()))?; + let admin = admin.user; let session = crate::db::queries::users::create_session( &conn, @@ -368,9 +376,9 @@ fn settings_json(s: &crate::db::queries::settings::InstanceSettings) -> serde_js /// GET /api/instance/settings — full settings, admin only. pub(super) async fn instance_settings_get( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, ) -> Result, LificError> { - require_admin(&auth_user)?; + require_admin(&identity)?; let s = with_read(&db, crate::db::queries::settings::get)?; Ok(Json(settings_json(&s))) } @@ -393,10 +401,10 @@ pub(super) struct InstanceSettingsPatchReq { pub(super) async fn instance_settings_patch( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { - require_admin(&auth_user)?; + require_admin(&identity)?; let patch = crate::db::queries::settings::InstanceSettingsPatch { allow_signup: input.allow_signup, instance_name: input.instance_name, @@ -422,9 +430,11 @@ pub(super) async fn instance_settings_patch( pub(super) async fn auth_me( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, ) -> Result, LificError> { - let user = auth_user + let user = identity + .as_ref() + .map(|i| i.user.clone()) .ok_or_else(|| LificError::BadRequest("no user associated with this token".into()))?; // Fetch full user from DB to get all fields (email, etc.) @@ -451,10 +461,10 @@ pub(super) struct UpdateMeRequest { /// email). LIF-190. pub(super) async fn update_me( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { - let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?; + let user = identity.as_ref().map(|i| i.user.clone()).ok_or_else(|| LificError::BadRequest("authentication required".into()))?; let full = with_write(&db, |conn| { crate::db::queries::users::update_profile( conn, @@ -489,10 +499,10 @@ pub(super) struct ChangePasswordRequest { pub(super) async fn change_password( State(db): State, Extension(auth_cfg): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result { - let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?; + let user = identity.as_ref().map(|i| i.user.clone()).ok_or_else(|| LificError::BadRequest("authentication required".into()))?; let session = with_write(&db, |conn| { let full = crate::db::queries::users::get_user_by_id(conn, user.id)?; let ok = crate::db::queries::users::verify_password( @@ -534,9 +544,9 @@ pub(super) async fn change_password( pub(super) async fn revoke_all_sessions( State(db): State, Extension(auth_cfg): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, ) -> Result { - let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?; + let user = identity.as_ref().map(|i| i.user.clone()).ok_or_else(|| LificError::BadRequest("authentication required".into()))?; with_write(&db, |conn| { crate::db::queries::users::delete_all_sessions(conn, user.id) })?; @@ -552,9 +562,9 @@ pub(super) async fn revoke_all_sessions( pub(super) async fn list_keys( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, ) -> Result>, LificError> { - let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?; + let user = identity.as_ref().map(|i| i.user.clone()).ok_or_else(|| LificError::BadRequest("authentication required".into()))?; with_read(&db, |conn| { crate::db::queries::users::list_user_keys(conn, user.id) @@ -569,11 +579,11 @@ pub(super) struct CreateKeyRequest { pub(super) async fn create_key( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Extension(manager): Extension>, Json(input): Json, ) -> Result, LificError> { - let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?; + let user = identity.as_ref().map(|i| i.user.clone()).ok_or_else(|| LificError::BadRequest("authentication required".into()))?; let name = input.name.trim().to_string(); if name.is_empty() { @@ -594,9 +604,9 @@ pub(super) async fn create_key( pub(super) async fn revoke_key( State(db): State, Path(id): Path, - Extension(auth_user): Extension>, + Extension(identity): Extension>, ) -> Result, LificError> { - let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?; + let user = identity.as_ref().map(|i| i.user.clone()).ok_or_else(|| LificError::BadRequest("authentication required".into()))?; let conn = db.write()?; crate::db::queries::users::revoke_user_key(&conn, id, user.id, user.is_admin)?; @@ -608,9 +618,9 @@ pub(super) async fn revoke_key( pub(super) async fn list_bots( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, ) -> Result>, LificError> { - let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?; + let user = identity.as_ref().map(|i| i.user.clone()).ok_or_else(|| LificError::BadRequest("authentication required".into()))?; with_read(&db, |conn| { crate::db::queries::users::list_bots(conn, user.id) @@ -627,11 +637,11 @@ pub(super) struct CreateBotRequest { pub(super) async fn create_bot( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Extension(manager): Extension>, Json(input): Json, ) -> Result, LificError> { - let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?; + let user = identity.as_ref().map(|i| i.user.clone()).ok_or_else(|| LificError::BadRequest("authentication required".into()))?; let tool = input.tool.trim().to_lowercase(); let display_name = match tool.as_str() { @@ -648,32 +658,23 @@ pub(super) async fn create_bot( let bot_username = format!("{tool}-{}", user.username); - // Check if a disconnected bot already exists — reconnect it instead of creating new - let existing_bot = with_read(&db, |conn| { - crate::db::queries::users::find_bot_by_username(conn, &bot_username) - }) - .ok() - .flatten(); - - let bot_user = if let Some(existing) = existing_bot { - // Bot exists — check if it already has an active key - let has_key = with_read(&db, |conn| { - crate::db::queries::users::bot_has_active_key(conn, existing.id) - })?; - - if has_key { - return Err(LificError::BadRequest(format!( - "{display_name} is already connected" - ))); - } + // Reuse the shared find-or-create seam (LIFIC-13) so a web-connected bot is + // indistinguishable from one minted at OAuth approval or via `lific connect`. + let bot_user = with_write(&db, |conn| { + crate::db::queries::users::ensure_bot(conn, user.id, &tool, display_name) + })?; - existing - } else { - // Create fresh bot user - with_write(&db, |conn| { - crate::db::queries::users::create_bot_user(conn, user.id, &bot_username, display_name) - })? - }; + // If the bot already has a live credential (API key or OAuth token) it's + // already connected — refuse rather than silently minting a fresh + // credential for an active tool. + let connected = with_read(&db, |conn| { + crate::db::queries::users::bot_is_connected(conn, bot_user.id) + })?; + if connected { + return Err(LificError::BadRequest(format!( + "{display_name} is already connected" + ))); + } // Generate a new API key for the bot let plaintext_key = crate::auth::create_api_key(&db, &manager, &bot_username)?; @@ -696,9 +697,9 @@ pub(super) async fn create_bot( pub(super) async fn disconnect_bot( State(db): State, Path(id): Path, - Extension(auth_user): Extension>, + Extension(identity): Extension>, ) -> Result, LificError> { - let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?; + let user = identity.as_ref().map(|i| i.user.clone()).ok_or_else(|| LificError::BadRequest("authentication required".into()))?; let conn = db.write()?; crate::db::queries::users::disconnect_bot(&conn, id, user.id, user.is_admin)?; @@ -709,9 +710,9 @@ pub(super) async fn disconnect_bot( pub(super) async fn delete_bot( State(db): State, Path(id): Path, - Extension(auth_user): Extension>, + Extension(identity): Extension>, ) -> Result, LificError> { - let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?; + let user = identity.as_ref().map(|i| i.user.clone()).ok_or_else(|| LificError::BadRequest("authentication required".into()))?; let conn = db.write()?; crate::db::queries::users::delete_bot(&conn, id, user.id, user.is_admin)?; diff --git a/src/api/comments.rs b/src/api/comments.rs index c0400b21..b486b725 100644 --- a/src/api/comments.rs +++ b/src/api/comments.rs @@ -45,12 +45,12 @@ fn create_comment_with_mentions( /// Workspace-level pages (`project_id = None`) fall back to admin-only. fn require_comment_viewer( db: &DbPool, - auth_user: &Option, + identity: &Option, project_id: Option, ) -> Result<(), LificError> { match project_id { - Some(pid) => authz::require_role(db, auth_user, pid, Role::Viewer), - None => authz::require_workspace_admin(db, auth_user), + Some(pid) => authz::require_role(db, identity, pid, Role::Viewer), + None => authz::require_workspace_admin(db, identity), } } @@ -68,13 +68,13 @@ pub(super) struct ListCommentsQuery { pub(super) async fn list_comments( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(issue_id): Path, Query(q): Query, ) -> Result>, LificError> { let project_id = with_read(&db, |conn| crate::db::queries::get_issue(conn, issue_id))?.project_id; - require_comment_viewer(&db, &auth_user, Some(project_id))?; + require_comment_viewer(&db, &identity, Some(project_id))?; let limit = q.limit.map(|n| n.clamp(1, 500)); with_read(&db, |conn| { crate::db::queries::comments::list_comments_paginated( @@ -93,14 +93,16 @@ pub(super) async fn create_comment( State(db): State, Extension(realtime): Extension, Path(issue_id): Path, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { let project_id = with_read(&db, |conn| crate::db::queries::get_issue(conn, issue_id))?.project_id; - require_comment_viewer(&db, &auth_user, Some(project_id))?; + require_comment_viewer(&db, &identity, Some(project_id))?; - let user = auth_user + let user = identity + .as_ref() + .map(|i| i.user.clone()) .ok_or_else(|| LificError::BadRequest("authentication required to comment".into()))?; let comment = create_comment_with_mentions( @@ -116,12 +118,12 @@ pub(super) async fn create_comment( pub(super) async fn list_page_comments( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(page_id): Path, Query(q): Query, ) -> Result>, LificError> { let project_id = with_read(&db, |conn| crate::db::queries::get_page(conn, page_id))?.project_id; - require_comment_viewer(&db, &auth_user, project_id)?; + require_comment_viewer(&db, &identity, project_id)?; let limit = q.limit.map(|n| n.clamp(1, 500)); with_read(&db, |conn| { crate::db::queries::comments::list_comments_paginated( @@ -140,13 +142,15 @@ pub(super) async fn create_page_comment( State(db): State, Extension(realtime): Extension, Path(page_id): Path, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { let project_id = with_read(&db, |conn| crate::db::queries::get_page(conn, page_id))?.project_id; - require_comment_viewer(&db, &auth_user, project_id)?; + require_comment_viewer(&db, &identity, project_id)?; - let user = auth_user + let user = identity + .as_ref() + .map(|i| i.user.clone()) .ok_or_else(|| LificError::BadRequest("authentication required to comment".into()))?; let comment = create_comment_with_mentions( @@ -169,10 +173,10 @@ pub(super) async fn create_page_comment( /// user who can't see the project. Powers the composer autocomplete. pub(super) async fn mention_candidates( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(project_id): Path, ) -> Result>, LificError> { - authz::require_role(&db, &auth_user, project_id, Role::Viewer)?; + authz::require_role(&db, &identity, project_id, Role::Viewer)?; let member_scoped = authz::authz_enforced(&db)?; with_read(&db, |conn| { comments::mention_candidates(conn, Some(project_id), member_scoped) @@ -184,10 +188,10 @@ pub(super) async fn update_comment_handler( State(db): State, Extension(realtime): Extension, Path(id): Path, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { - let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?; + let user = identity.as_ref().map(|i| i.user.clone()).ok_or_else(|| LificError::BadRequest("authentication required".into()))?; // Check ownership: only the author or an admin can edit let existing = with_read(&db, |conn| { @@ -251,9 +255,9 @@ pub(super) async fn delete_comment_handler( State(db): State, Extension(realtime): Extension, Path(id): Path, - Extension(auth_user): Extension>, + Extension(identity): Extension>, ) -> Result, LificError> { - let user = auth_user.ok_or_else(|| LificError::BadRequest("authentication required".into()))?; + let user = identity.as_ref().map(|i| i.user.clone()).ok_or_else(|| LificError::BadRequest("authentication required".into()))?; // Check ownership: only the author or an admin can delete let existing = with_read(&db, |conn| { @@ -358,6 +362,15 @@ mod tests { username: user.username.clone(), display_name: user.display_name.clone(), is_admin: user.is_admin, + }))) + .layer(Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: user.id, + username: user.username.clone(), + display_name: user.display_name.clone(), + is_admin: user.is_admin, + }, + transport: crate::actor::Transport::Web, }))); (app, issue.id, user.id) @@ -558,9 +571,18 @@ mod tests { })) .layer(Extension(Some(AuthUser { id: other.id, - username: other.username, - display_name: other.display_name, + username: other.username.clone(), + display_name: other.display_name.clone(), is_admin: false, + }))) + .layer(Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: other.id, + username: other.username, + display_name: other.display_name, + is_admin: false, + }, + transport: crate::actor::Transport::Web, }))); // Try to edit owner's comment @@ -673,9 +695,18 @@ mod tests { })) .layer(Extension(Some(AuthUser { id: admin.id, - username: admin.username, - display_name: admin.display_name, + username: admin.username.clone(), + display_name: admin.display_name.clone(), is_admin: true, + }))) + .layer(Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: admin.id, + username: admin.username, + display_name: admin.display_name, + is_admin: true, + }, + transport: crate::actor::Transport::Web, }))); // Admin can delete regular user's comment @@ -756,6 +787,15 @@ mod tests { username: user.username.clone(), display_name: user.display_name.clone(), is_admin: user.is_admin, + }))) + .layer(Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: user.id, + username: user.username.clone(), + display_name: user.display_name.clone(), + is_admin: user.is_admin, + }, + transport: crate::actor::Transport::Web, }))); (app, page_id, user.id) @@ -939,9 +979,18 @@ mod tests { })) .layer(Extension(Some(AuthUser { id: other.id, - username: other.username, - display_name: other.display_name, + username: other.username.clone(), + display_name: other.display_name.clone(), is_admin: false, + }))) + .layer(Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: other.id, + username: other.username, + display_name: other.display_name, + is_admin: false, + }, + transport: crate::actor::Transport::Web, }))); // Try to edit owner's page comment as a non-owner, non-admin user @@ -1033,9 +1082,18 @@ mod tests { })) .layer(Extension(Some(AuthUser { id: admin.id, - username: admin.username, - display_name: admin.display_name, + username: admin.username.clone(), + display_name: admin.display_name.clone(), is_admin: true, + }))) + .layer(Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: admin.id, + username: admin.username, + display_name: admin.display_name, + is_admin: true, + }, + transport: crate::actor::Transport::Web, }))); let resp = app @@ -1415,9 +1473,18 @@ mod tests { })) .layer(Extension(Some(AuthUser { id: user.id, - username: user.username, - display_name: user.display_name, + username: user.username.clone(), + display_name: user.display_name.clone(), is_admin: false, + }))) + .layer(Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: user.id, + username: user.username, + display_name: user.display_name, + is_admin: false, + }, + transport: crate::actor::Transport::Web, }))); // Post one comment to the issue and one to the page. diff --git a/src/api/export.rs b/src/api/export.rs index e365b471..a43c5777 100644 --- a/src/api/export.rs +++ b/src/api/export.rs @@ -5,7 +5,7 @@ use axum::response::IntoResponse; use crate::authz; use crate::db::DbPool; -use crate::db::models::{AuthUser, Role}; +use crate::db::models::Role; use crate::error::LificError; use super::with_read; @@ -22,14 +22,14 @@ fn content_disposition(filename: &str) -> Result { pub(super) async fn export_issue( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(identifier): Path, ) -> Result { let project_id = with_read(&db, |conn| { let id = crate::db::queries::resolve_identifier(conn, &identifier)?; Ok(crate::db::queries::get_issue(conn, id)?.project_id) })?; - authz::require_role(&db, &auth_user, project_id, Role::Viewer)?; + authz::require_role(&db, &identity, project_id, Role::Viewer)?; let bundle = with_read(&db, |conn| crate::export::export_issue(conn, &identifier))?; let file = bundle .files @@ -54,7 +54,7 @@ pub(super) async fn export_issue( pub(super) async fn export_page( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(identifier): Path, ) -> Result { let project_id = with_read(&db, |conn| { @@ -62,8 +62,8 @@ pub(super) async fn export_page( Ok(crate::db::queries::get_page(conn, id)?.project_id) })?; match project_id { - Some(pid) => authz::require_role(&db, &auth_user, pid, Role::Viewer)?, - None => authz::require_workspace_admin(&db, &auth_user)?, + Some(pid) => authz::require_role(&db, &identity, pid, Role::Viewer)?, + None => authz::require_workspace_admin(&db, &identity)?, } let bundle = with_read(&db, |conn| crate::export::export_page(conn, &identifier))?; let file = bundle @@ -89,14 +89,14 @@ pub(super) async fn export_page( pub(super) async fn export_project( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(identifier): Path, Query(q): Query, ) -> Result { let project_id = with_read(&db, |conn| { crate::db::queries::resolve_project_identifier(conn, &identifier) })?; - authz::require_role(&db, &auth_user, project_id, Role::Viewer)?; + authz::require_role(&db, &identity, project_id, Role::Viewer)?; let format = q.format.as_deref().unwrap_or("zip"); let bundle = with_read(&db, |conn| crate::export::export_project(conn, &identifier))?; diff --git a/src/api/insights.rs b/src/api/insights.rs index d478e410..46b1930b 100644 --- a/src/api/insights.rs +++ b/src/api/insights.rs @@ -12,7 +12,7 @@ use crate::authz; use crate::db::queries::insights::{clamp_weeks, get_insights}; use crate::db::{ DbPool, - models::{AuthUser, InsightsPayload, Role}, + models::{InsightsPayload, Role}, }; use crate::error::LificError; @@ -28,11 +28,11 @@ pub(super) struct InsightsQuery { /// GET /api/projects/{id}/insights?weeks=N pub(super) async fn project_insights( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, Query(q): Query, ) -> Result, LificError> { - authz::require_role(&db, &auth_user, id, Role::Viewer)?; + authz::require_role(&db, &identity, id, Role::Viewer)?; let weeks = clamp_weeks(q.weeks); with_read(&db, |conn| get_insights(conn, id, weeks)).map(Json) } diff --git a/src/api/issues.rs b/src/api/issues.rs index 0ce7fc63..14b67e25 100644 --- a/src/api/issues.rs +++ b/src/api/issues.rs @@ -12,15 +12,15 @@ use super::{filter_visible, with_read, with_write}; pub(super) async fn list_issues( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Query(q): Query, ) -> Result>, LificError> { if let Some(pid) = q.project_id { - authz::require_role(&db, &auth_user, pid, Role::Viewer)?; + authz::require_role(&db, &identity, pid, Role::Viewer)?; return with_read(&db, |conn| crate::db::queries::list_issues(conn, &q)).map(Json); } // Cross-project list: filter instead of denying (LIF-197 scope item 2). - let visible = authz::visible_project_ids(&db, &auth_user)?; + let visible = authz::visible_project_ids(&db, &identity)?; let issues = with_read(&db, |conn| crate::db::queries::list_issues(conn, &q))?; Ok(Json(filter_visible(issues, &visible, |i| { Some(i.project_id) @@ -29,34 +29,34 @@ pub(super) async fn list_issues( pub(super) async fn get_issue( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, ) -> Result, LificError> { let issue = with_read(&db, |conn| crate::db::queries::get_issue(conn, id))?; - authz::require_role(&db, &auth_user, issue.project_id, Role::Viewer)?; + authz::require_role(&db, &identity, issue.project_id, Role::Viewer)?; Ok(Json(issue)) } pub(super) async fn resolve_issue( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(identifier): Path, ) -> Result, LificError> { let issue = with_read(&db, |conn| { let id = crate::db::queries::resolve_identifier(conn, &identifier)?; crate::db::queries::get_issue(conn, id) })?; - authz::require_role(&db, &auth_user, issue.project_id, Role::Viewer)?; + authz::require_role(&db, &identity, issue.project_id, Role::Viewer)?; Ok(Json(issue)) } pub(super) async fn create_issue( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { - authz::require_role(&db, &auth_user, input.project_id, Role::Maintainer)?; + authz::require_role(&db, &identity, input.project_id, Role::Maintainer)?; let issue = with_write(&db, |conn| { let issue = crate::db::queries::create_issue(conn, &input)?; // LIF-262: link any attachments the description references. @@ -78,12 +78,12 @@ pub(super) async fn create_issue( pub(super) async fn update_issue( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, Json(input): Json, ) -> Result, LificError> { let project_id = with_read(&db, |conn| crate::db::queries::get_issue(conn, id))?.project_id; - authz::require_role(&db, &auth_user, project_id, Role::Maintainer)?; + authz::require_role(&db, &identity, project_id, Role::Maintainer)?; let issue = with_write(&db, |conn| { let issue = crate::db::queries::update_issue(conn, id, &input)?; // LIF-262: re-scan the (possibly edited) description and reconcile links. @@ -105,11 +105,11 @@ pub(super) async fn update_issue( pub(super) async fn delete_issue_handler( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, ) -> Result, LificError> { let project_id = with_read(&db, |conn| crate::db::queries::get_issue(conn, id))?.project_id; - authz::require_role(&db, &auth_user, project_id, Role::Maintainer)?; + authz::require_role(&db, &identity, project_id, Role::Maintainer)?; let issue = with_write(&db, |conn| { let issue = crate::db::queries::get_issue(conn, id)?; crate::db::queries::delete_issue(conn, id)?; @@ -138,7 +138,7 @@ pub(super) struct UnlinkRequest { pub(super) async fn link_issues( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { let (source, target) = with_read(&db, |conn| { @@ -151,8 +151,8 @@ pub(super) async fn link_issues( })?; // Cross-project relation: the caller must be a Maintainer on BOTH sides // (LIF-197 scope item 3), even when source and target share a project. - authz::require_role(&db, &auth_user, source.project_id, Role::Maintainer)?; - authz::require_role(&db, &auth_user, target.project_id, Role::Maintainer)?; + authz::require_role(&db, &identity, source.project_id, Role::Maintainer)?; + authz::require_role(&db, &identity, target.project_id, Role::Maintainer)?; with_write(&db, |conn| { crate::db::queries::link_issues(conn, source.id, target.id, &input.relation_type) @@ -171,7 +171,7 @@ pub(super) async fn link_issues( pub(super) async fn unlink_issues( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { let (source, target) = with_read(&db, |conn| { @@ -182,8 +182,8 @@ pub(super) async fn unlink_issues( crate::db::queries::get_issue(conn, target_id)?, )) })?; - authz::require_role(&db, &auth_user, source.project_id, Role::Maintainer)?; - authz::require_role(&db, &auth_user, target.project_id, Role::Maintainer)?; + authz::require_role(&db, &identity, source.project_id, Role::Maintainer)?; + authz::require_role(&db, &identity, target.project_id, Role::Maintainer)?; with_write(&db, |conn| { crate::db::queries::unlink_issues(conn, source.id, target.id) diff --git a/src/api/members.rs b/src/api/members.rs index 87ec9ff3..05c0f053 100644 --- a/src/api/members.rs +++ b/src/api/members.rs @@ -38,10 +38,10 @@ use super::{with_read, with_write}; /// (`Viewer`+); non-members are denied same as any other project read. pub(super) async fn list_project_members( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(project_id): Path, ) -> Result>, LificError> { - authz::require_role(&db, &auth_user, project_id, Role::Viewer)?; + authz::require_role(&db, &identity, project_id, Role::Viewer)?; with_read(&db, |conn| { members::list_members_with_users(conn, project_id) }) @@ -69,13 +69,14 @@ pub(super) async fn list_project_members( /// access," never as "full access." pub(super) async fn my_project_role( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(project_id): Path, ) -> Result, LificError> { - authz::require_role(&db, &auth_user, project_id, Role::Viewer)?; + authz::require_role(&db, &identity, project_id, Role::Viewer)?; let enforced = authz::authz_enforced(&db)?; let (role, is_admin) = with_read(&db, |conn| { + let auth_user = identity.as_ref().map(|i| i.user.clone()); let effective = authz::effective_user(conn, &auth_user); let is_admin = matches!(&effective, Some(u) if u.is_admin); let role = match &effective { @@ -99,11 +100,11 @@ pub(super) async fn my_project_role( pub(super) async fn add_project_member( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(project_id): Path, Json(input): Json, ) -> Result, LificError> { - authz::require_role(&db, &auth_user, project_id, Role::Lead)?; + authz::require_role(&db, &identity, project_id, Role::Lead)?; let role = input.role.as_deref().unwrap_or("viewer").to_string(); let member = with_write(&db, |conn| { members::add_member(conn, project_id, input.user_id, &role) @@ -118,11 +119,11 @@ pub(super) async fn add_project_member( pub(super) async fn update_project_member( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path((project_id, user_id)): Path<(i64, i64)>, Json(input): Json, ) -> Result, LificError> { - authz::require_role(&db, &auth_user, project_id, Role::Lead)?; + authz::require_role(&db, &identity, project_id, Role::Lead)?; let member = with_write(&db, |conn| { members::change_role(conn, project_id, user_id, &input.role) })?; @@ -135,10 +136,10 @@ pub(super) async fn update_project_member( pub(super) async fn remove_project_member( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path((project_id, user_id)): Path<(i64, i64)>, ) -> Result, LificError> { - authz::require_role(&db, &auth_user, project_id, Role::Lead)?; + authz::require_role(&db, &identity, project_id, Role::Lead)?; with_write(&db, |conn| { members::remove_member_guarded(conn, project_id, user_id) })?; diff --git a/src/api/mod.rs b/src/api/mod.rs index 1dae7a07..87895cc7 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -471,10 +471,10 @@ where /// split. fn require_project_lead( db: &DbPool, - auth_user: &Option, + identity: &Option, project_id: i64, ) -> Result<(), LificError> { - crate::authz::require_role(db, auth_user, project_id, Role::Lead) + crate::authz::require_role(db, identity, project_id, Role::Lead) } /// LIF-197: thin wrapper over `authz::require_structure_role` for the @@ -482,10 +482,10 @@ fn require_project_lead( /// comment for why it can't just be `require_role(.., Maintainer)`. fn require_structure_role( db: &DbPool, - auth_user: &Option, + identity: &Option, project_id: i64, ) -> Result<(), LificError> { - crate::authz::require_structure_role(db, auth_user, project_id) + crate::authz::require_structure_role(db, identity, project_id) } /// LIF-197: thin wrapper over `authz::require_project_delete_role`, used by @@ -494,10 +494,10 @@ fn require_structure_role( /// to `require_role(.., Lead)`. fn require_project_delete( db: &DbPool, - auth_user: &Option, + identity: &Option, project_id: i64, ) -> Result<(), LificError> { - crate::authz::require_project_delete_role(db, auth_user, project_id) + crate::authz::require_project_delete_role(db, identity, project_id) } /// LIF-197: apply the `visible_project_ids` cross-project read filter to a @@ -523,19 +523,34 @@ fn filter_visible( /// Require any authenticated user (LIF-233). Used for low-stakes, instance-wide /// actions like sidebar project ordering, which shouldn't be gated behind /// per-project lead/admin rights the way structural project edits are. -/// Default-deny: returns Forbidden when auth_user is None. -fn require_authenticated(auth_user: &Option) -> Result<(), LificError> { - match auth_user { +/// Default-deny: returns Forbidden when identity is None. +/// +/// LIFIC-10: in passwordless mode (`[auth] required = false`) the middleware +/// resolves a `ResolvedIdentity` (first-admin fallback) even for a +/// credential-less request, so this passes — fixing the auth-off bug where +/// `/api/projects/reorder` previously 403'd. +fn require_authenticated( + identity: &Option, +) -> Result<(), LificError> { + match identity { Some(_) => Ok(()), None => Err(LificError::Forbidden("authentication required".into())), } } /// Check if the authenticated user is an admin. -/// Default-deny: returns Forbidden when auth_user is None (OAuth tokens, legacy keys). -fn require_admin(auth_user: &Option) -> Result<(), LificError> { - match auth_user { - Some(user) if user.is_admin => Ok(()), +/// Default-deny: returns Forbidden when identity is None (legacy unbound +/// OAuth token pre-LIFIC-9 bootstrap, or a resolve failure). +/// +/// LIFIC-10: consumes `ResolvedIdentity`. An unbound API key resolves to the +/// first admin (via `resolve_caller`), so it now passes — fixing the auth-off +/// bug where `/api/instance/settings` previously 403'd. The separate operator +/// signal is gone; `identity.user.is_admin` is the single check. +fn require_admin( + identity: &Option, +) -> Result<(), LificError> { + match identity { + Some(i) if i.user.is_admin => Ok(()), _ => Err(LificError::Forbidden("only an admin can do this".into())), } } @@ -548,7 +563,7 @@ async fn health() -> &'static str { async fn search( State(db): State, - axum::Extension(auth_user): axum::Extension>, + axum::Extension(identity): axum::Extension>, Query(q): Query, ) -> Result>, LificError> { // Cross-project read (LIF-197 scope item 2): non-visible projects are @@ -556,7 +571,7 @@ async fn search( // narrows the search to one project, since a non-member of that project // shouldn't be able to probe its existence via a 403 vs. empty-results // side channel here. - let visible = crate::authz::visible_project_ids(&db, &auth_user)?; + let visible = crate::authz::visible_project_ids(&db, &identity)?; let results = with_read(&db, |conn| queries::search(conn, &q))?; Ok(Json(filter_visible(results, &visible, |r| r.project_id))) } @@ -676,6 +691,33 @@ pub(crate) mod test_helpers { username: "test-admin".into(), display_name: "Test Admin".into(), is_admin: true, + }))) + .layer(Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: admin_id, + username: "test-admin".into(), + display_name: "Test Admin".into(), + is_admin: true, + }, + transport: crate::actor::Transport::Web, + }))) + .layer(Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: admin_id, + username: "test-admin".into(), + display_name: "Test Admin".into(), + is_admin: true, + }, + transport: crate::actor::Transport::Web, + }))) + .layer(Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: admin_id, + username: "test-admin".into(), + display_name: "Test Admin".into(), + is_admin: true, + }, + transport: crate::actor::Transport::Web, }))); RealtimeTestApp { app, realtime } } @@ -792,6 +834,16 @@ pub(crate) mod test_helpers { /// Build a test app authenticated as a specific user. pub fn app_as_user(db: DbPool, user: &User) -> Router { + let auth_user = AuthUser { + id: user.id, + username: user.username.clone(), + display_name: user.display_name.clone(), + is_admin: user.is_admin, + }; + let identity = crate::resolve_caller::ResolvedIdentity { + user: auth_user.clone(), + transport: crate::actor::Transport::Web, + }; with_client_ip_test_layers(with_attachment_layers(super::router(db, &[])), test_peer()) .layer(Extension(crate::realtime::RealtimeHub::new())) .layer(Extension(crate::config::AuthConfig { @@ -799,12 +851,8 @@ pub(crate) mod test_helpers { required: true, secure_cookies: false, })) - .layer(Extension(Some(AuthUser { - id: user.id, - username: user.username.clone(), - display_name: user.display_name.clone(), - is_admin: user.is_admin, - }))) + .layer(Extension(Some(auth_user))) + .layer(Extension(Some(identity))) } /// Set up a DB with an admin, a project lead, a regular user, and a project. diff --git a/src/api/pages.rs b/src/api/pages.rs index bb77391d..bd686361 100644 --- a/src/api/pages.rs +++ b/src/api/pages.rs @@ -17,13 +17,13 @@ use super::{filter_visible, with_read, with_write}; /// (design decision #10). fn require_page_role( db: &DbPool, - auth_user: &Option, + identity: &Option, project_id: Option, min: Role, ) -> Result<(), LificError> { match project_id { - Some(pid) => authz::require_role(db, auth_user, pid, min), - None => authz::require_workspace_admin(db, auth_user), + Some(pid) => authz::require_role(db, identity, pid, min), + None => authz::require_workspace_admin(db, identity), } } @@ -50,11 +50,11 @@ pub(super) struct PageQuery { pub(super) async fn list_pages_handler( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Query(q): Query, ) -> Result>, LificError> { if let Some(pid) = q.project_id { - authz::require_role(&db, &auth_user, pid, Role::Viewer)?; + authz::require_role(&db, &identity, pid, Role::Viewer)?; return with_read(&db, |conn| { crate::db::queries::list_pages( conn, @@ -73,7 +73,7 @@ pub(super) async fn list_pages_handler( // Cross-project list (LIF-197 scope item 2): filter, don't deny. A // workspace page (project_id None) is excluded for any non-admin once // enforcement is on — see `filter_visible`'s doc comment. - let visible = authz::visible_project_ids(&db, &auth_user)?; + let visible = authz::visible_project_ids(&db, &identity)?; let pages = with_read(&db, |conn| { crate::db::queries::list_pages( conn, @@ -92,34 +92,34 @@ pub(super) async fn list_pages_handler( pub(super) async fn get_page( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, ) -> Result, LificError> { let page = with_read(&db, |conn| crate::db::queries::get_page(conn, id))?; - require_page_role(&db, &auth_user, page.project_id, Role::Viewer)?; + require_page_role(&db, &identity, page.project_id, Role::Viewer)?; Ok(Json(page)) } pub(super) async fn resolve_page( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(identifier): Path, ) -> Result, LificError> { let page = with_read(&db, |conn| { let id = crate::db::queries::resolve_page_identifier(conn, &identifier)?; crate::db::queries::get_page(conn, id) })?; - require_page_role(&db, &auth_user, page.project_id, Role::Viewer)?; + require_page_role(&db, &identity, page.project_id, Role::Viewer)?; Ok(Json(page)) } pub(super) async fn create_page( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { - require_page_role(&db, &auth_user, input.project_id, Role::Maintainer)?; + require_page_role(&db, &identity, input.project_id, Role::Maintainer)?; let page = with_write(&db, |conn| { let page = crate::db::queries::create_page(conn, &input)?; // LIF-262: link any attachments the content references. @@ -135,12 +135,12 @@ pub(super) async fn create_page( pub(super) async fn update_page( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, Json(input): Json, ) -> Result, LificError> { let project_id = with_read(&db, |conn| crate::db::queries::get_page(conn, id))?.project_id; - require_page_role(&db, &auth_user, project_id, Role::Maintainer)?; + require_page_role(&db, &identity, project_id, Role::Maintainer)?; let page = with_write(&db, |conn| { let page = crate::db::queries::update_page(conn, id, &input)?; // LIF-262: re-scan the (possibly edited) content and reconcile links. @@ -156,11 +156,11 @@ pub(super) async fn update_page( pub(super) async fn delete_page_handler( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, ) -> Result, LificError> { let project_id = with_read(&db, |conn| crate::db::queries::get_page(conn, id))?.project_id; - require_page_role(&db, &auth_user, project_id, Role::Maintainer)?; + require_page_role(&db, &identity, project_id, Role::Maintainer)?; with_write(&db, |conn| crate::db::queries::delete_page(conn, id))?; if let Some(project_id) = project_id { realtime.send(RealtimeEvent::ProjectUpdated { project_id }); diff --git a/src/api/plans.rs b/src/api/plans.rs index 364e87b5..8bd468f9 100644 --- a/src/api/plans.rs +++ b/src/api/plans.rs @@ -13,49 +13,49 @@ use super::{filter_visible, with_read, with_write}; pub(super) async fn list_plans( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Query(q): Query, ) -> Result>, LificError> { if let Some(pid) = q.project_id { - authz::require_role(&db, &auth_user, pid, Role::Viewer)?; + authz::require_role(&db, &identity, pid, Role::Viewer)?; return with_read(&db, |conn| plans::list_plans(conn, &q)).map(Json); } // Cross-project list (LIF-197 scope item 2): filter, don't deny. - let visible = authz::visible_project_ids(&db, &auth_user)?; + let visible = authz::visible_project_ids(&db, &identity)?; let list = with_read(&db, |conn| plans::list_plans(conn, &q))?; Ok(Json(filter_visible(list, &visible, |p| Some(p.project_id)))) } pub(super) async fn get_plan( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, ) -> Result, LificError> { let plan = with_read(&db, |conn| plans::get_plan(conn, id))?; - authz::require_role(&db, &auth_user, plan.project_id, Role::Viewer)?; + authz::require_role(&db, &identity, plan.project_id, Role::Viewer)?; Ok(Json(plan)) } pub(super) async fn resolve_plan( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(identifier): Path, ) -> Result, LificError> { let plan = with_read(&db, |conn| { let id = plans::resolve_plan_identifier(conn, &identifier)?; plans::get_plan(conn, id) })?; - authz::require_role(&db, &auth_user, plan.project_id, Role::Viewer)?; + authz::require_role(&db, &identity, plan.project_id, Role::Viewer)?; Ok(Json(plan)) } pub(super) async fn create_plan( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { - authz::require_role(&db, &auth_user, input.project_id, Role::Maintainer)?; + authz::require_role(&db, &identity, input.project_id, Role::Maintainer)?; let plan = with_write(&db, |conn| plans::create_plan(conn, &input))?; realtime.send(RealtimeEvent::ProjectUpdated { project_id: plan.project_id }); Ok(Json(plan)) @@ -64,12 +64,12 @@ pub(super) async fn create_plan( pub(super) async fn update_plan( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, Json(input): Json, ) -> Result, LificError> { let project_id = with_read(&db, |conn| plans::get_plan(conn, id))?.project_id; - authz::require_role(&db, &auth_user, project_id, Role::Maintainer)?; + authz::require_role(&db, &identity, project_id, Role::Maintainer)?; let plan = with_write(&db, |conn| plans::update_plan(conn, id, &input))?; realtime.send(RealtimeEvent::ProjectUpdated { project_id }); Ok(Json(plan)) @@ -78,11 +78,11 @@ pub(super) async fn update_plan( pub(super) async fn delete_plan_handler( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, ) -> Result, LificError> { let project_id = with_read(&db, |conn| plans::get_plan(conn, id))?.project_id; - authz::require_role(&db, &auth_user, project_id, Role::Maintainer)?; + authz::require_role(&db, &identity, project_id, Role::Maintainer)?; with_write(&db, |conn| plans::delete_plan(conn, id))?; realtime.send(RealtimeEvent::ProjectUpdated { project_id }); Ok(Json(serde_json::json!({"deleted": true}))) @@ -100,12 +100,12 @@ pub(super) struct AddStepRequest { pub(super) async fn add_step( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(plan_id): Path, Json(input): Json, ) -> Result, LificError> { let project_id = with_read(&db, |conn| plans::get_plan(conn, plan_id))?.project_id; - authz::require_role(&db, &auth_user, project_id, Role::Maintainer)?; + authz::require_role(&db, &identity, project_id, Role::Maintainer)?; let plan = with_write(&db, |conn| { plans::add_step( conn, @@ -146,12 +146,12 @@ pub(super) struct StepUpdateResponse { pub(super) async fn update_step( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path((plan_id, step_id)): Path<(i64, i64)>, Json(input): Json, ) -> Result, LificError> { let project_id = with_read(&db, |conn| plans::get_plan(conn, plan_id))?.project_id; - authz::require_role(&db, &auth_user, project_id, Role::Maintainer)?; + authz::require_role(&db, &identity, project_id, Role::Maintainer)?; let (resp, issue_event) = with_write(&db, |conn| { plans::assert_step_in_plan(conn, plan_id, step_id)?; if let Some(ref t) = input.title { @@ -209,11 +209,11 @@ pub(super) async fn update_step( pub(super) async fn delete_step_handler( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path((plan_id, step_id)): Path<(i64, i64)>, ) -> Result, LificError> { let project_id = with_read(&db, |conn| plans::get_plan(conn, plan_id))?.project_id; - authz::require_role(&db, &auth_user, project_id, Role::Maintainer)?; + authz::require_role(&db, &identity, project_id, Role::Maintainer)?; let plan = with_write(&db, |conn| { plans::assert_step_in_plan(conn, plan_id, step_id)?; plans::delete_step(conn, step_id)?; diff --git a/src/api/project_groups.rs b/src/api/project_groups.rs index 8f1845f4..ccebb960 100644 --- a/src/api/project_groups.rs +++ b/src/api/project_groups.rs @@ -26,18 +26,22 @@ use super::{with_read, with_write}; /// `LificError` has no Unauthorized variant — Forbidden is what the codebase /// uses for "no authenticated caller", same as `views::require_user`. -fn require_user(auth_user: Option) -> Result { - auth_user.ok_or_else(|| { - LificError::Forbidden("authentication required to manage project groups".into()) - }) +fn require_user( + identity: Option, +) -> Result { + identity + .map(|i| i.user) + .ok_or_else(|| { + LificError::Forbidden("authentication required to manage project groups".into()) + }) } pub(super) async fn list_groups( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, ) -> Result>, LificError> { - let visible = authz::visible_project_ids(&db, &auth_user)?; - let user = require_user(auth_user)?; + let visible = authz::visible_project_ids(&db, &identity)?; + let user = require_user(identity)?; let mut groups = with_read(&db, |conn| project_groups::list_groups(conn, user.id))?; // A membership outlives the caller's access to that project when a // project_members row is revoked. Drop those ids rather than render a @@ -53,10 +57,10 @@ pub(super) async fn list_groups( pub(super) async fn create_group( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { - let user = require_user(auth_user)?; + let user = require_user(identity)?; let group = with_write(&db, |conn| { project_groups::create_group(conn, user.id, &input) })?; @@ -67,11 +71,11 @@ pub(super) async fn create_group( pub(super) async fn update_group( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, Json(input): Json, ) -> Result, LificError> { - let user = require_user(auth_user)?; + let user = require_user(identity)?; let group = with_write(&db, |conn| { project_groups::update_group(conn, id, user.id, &input) })?; @@ -82,10 +86,10 @@ pub(super) async fn update_group( pub(super) async fn delete_group( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, ) -> Result, LificError> { - let user = require_user(auth_user)?; + let user = require_user(identity)?; let deleted = with_write(&db, |conn| project_groups::delete_group(conn, id, user.id))?; realtime.send_to_users(RealtimeEvent::ProjectGroupsChanged, vec![user.id]); Ok(Json(serde_json::json!({ "deleted": deleted }))) @@ -94,11 +98,11 @@ pub(super) async fn delete_group( pub(super) async fn assign_project( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { - authz::require_role(&db, &auth_user, input.project_id, Role::Viewer)?; - let user = require_user(auth_user)?; + authz::require_role(&db, &identity, input.project_id, Role::Viewer)?; + let user = require_user(identity)?; with_write(&db, |conn| { project_groups::assign_project(conn, user.id, input.project_id, input.group_id) })?; diff --git a/src/api/projects.rs b/src/api/projects.rs index 9a1ad329..9da737c6 100644 --- a/src/api/projects.rs +++ b/src/api/projects.rs @@ -15,37 +15,37 @@ use super::{ pub(super) async fn list_projects( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, ) -> Result>, LificError> { // Cross-project list (LIF-197 scope item 2): filter, don't deny. - let visible = authz::visible_project_ids(&db, &auth_user)?; + let visible = authz::visible_project_ids(&db, &identity)?; let projects = with_read(&db, crate::db::queries::list_projects)?; Ok(Json(filter_visible(projects, &visible, |p| Some(p.id)))) } pub(super) async fn get_project( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, ) -> Result, LificError> { let project = with_read(&db, |conn| crate::db::queries::get_project(conn, id))?; - authz::require_role(&db, &auth_user, project.id, Role::Viewer)?; + authz::require_role(&db, &identity, project.id, Role::Viewer)?; Ok(Json(project)) } pub(super) async fn create_project( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(mut input): Json, ) -> Result, LificError> { // LIF-102 fix #1: if no lead was supplied, default to the authenticated // creator. This prevents the "unowned project" trap where require_project_lead // rejects everyone except admins. if input.lead_user_id.is_none() - && let Some(user) = &auth_user + && let Some(i) = &identity { - input.lead_user_id = Some(user.id); + input.lead_user_id = Some(i.user.id); } let project = with_write(&db, |conn| crate::db::queries::create_project(conn, &input))?; realtime.send(RealtimeEvent::ProjectCreated { @@ -58,10 +58,10 @@ pub(super) async fn update_project( State(db): State, Extension(realtime): Extension, Path(id): Path, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { - require_project_lead(&db, &auth_user, id)?; + require_project_lead(&db, &identity, id)?; let project = with_write(&db, |conn| { crate::db::queries::update_project(conn, id, &input) })?; @@ -79,10 +79,10 @@ pub(super) async fn update_project( pub(super) async fn reorder_projects( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result>, LificError> { - require_authenticated(&auth_user)?; + require_authenticated(&identity)?; let projects = with_write(&db, |conn| { crate::db::queries::reorder_projects(conn, &input.ids) })?; @@ -94,9 +94,9 @@ pub(super) async fn delete_project_handler( State(db): State, Extension(realtime): Extension, Path(id): Path, - Extension(auth_user): Extension>, + Extension(identity): Extension>, ) -> Result, LificError> { - require_project_delete(&db, &auth_user, id)?; + require_project_delete(&db, &identity, id)?; let (project, audience) = with_write(&db, |conn| { crate::db::queries::delete_project_with_audience(conn, id) })?; @@ -115,10 +115,10 @@ pub(super) async fn delete_project_handler( /// client-side silently undercounts past the cap. pub(super) async fn issue_counts( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(project_id): Path, ) -> Result, LificError> { - authz::require_role(&db, &auth_user, project_id, Role::Viewer)?; + authz::require_role(&db, &identity, project_id, Role::Viewer)?; with_read(&db, |conn| { crate::db::queries::count_issues_by_status(conn, project_id) }) @@ -137,11 +137,11 @@ fn default_group_by() -> String { pub(super) async fn get_board( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(project_id): Path, Query(q): Query, ) -> Result, LificError> { - authz::require_role(&db, &auth_user, project_id, Role::Viewer)?; + authz::require_role(&db, &identity, project_id, Role::Viewer)?; let issues = with_read(&db, |conn| { crate::db::queries::list_issues( conn, @@ -236,16 +236,16 @@ fn default_map_closed() -> String { pub(super) async fn import_github( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(project_id): Path, Json(req): Json, ) -> Result, LificError> { - require_project_lead(&db, &auth_user, project_id)?; + require_project_lead(&db, &identity, project_id)?; // Resolve the import-bot owner from the authenticated user (the bot is // owned by whoever ran the import), so audit provenance is correct. On a // dry run we skip bot creation entirely. - let owner_id = auth_user.as_ref().map(|u| u.id); + let owner_id = identity.as_ref().map(|i| i.user.id); let dry_run = req.dry_run; let db2 = db.clone(); @@ -529,6 +529,24 @@ mod tests { username: "test-admin".into(), display_name: "Test Admin".into(), is_admin: true, + }))) + .layer(Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: admin_id, + username: "test-admin".into(), + display_name: "Test Admin".into(), + is_admin: true, + }, + transport: crate::actor::Transport::Web, + }))) + .layer(Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: admin_id, + username: "test-admin".into(), + display_name: "Test Admin".into(), + is_admin: true, + }, + transport: crate::actor::Transport::Web, }))); let (project_id, _) = seed_project(&app).await; diff --git a/src/api/resources.rs b/src/api/resources.rs index 6350a78d..01d17856 100644 --- a/src/api/resources.rs +++ b/src/api/resources.rs @@ -19,10 +19,10 @@ pub(super) struct ModuleQuery { pub(super) async fn list_modules( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Query(q): Query, ) -> Result>, LificError> { - authz::require_role(&db, &auth_user, q.project_id, Role::Viewer)?; + authz::require_role(&db, &identity, q.project_id, Role::Viewer)?; with_read(&db, |conn| { crate::db::queries::list_modules(conn, q.project_id) }) @@ -31,21 +31,21 @@ pub(super) async fn list_modules( pub(super) async fn get_module( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(id): Path, ) -> Result, LificError> { let module = with_read(&db, |conn| crate::db::queries::get_module(conn, id))?; - authz::require_role(&db, &auth_user, module.project_id, Role::Viewer)?; + authz::require_role(&db, &identity, module.project_id, Role::Viewer)?; Ok(Json(module)) } pub(super) async fn create_module( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { - require_structure_role(&db, &auth_user, input.project_id)?; + require_structure_role(&db, &identity, input.project_id)?; let module = with_write(&db, |conn| crate::db::queries::create_module(conn, &input))?; realtime.send(RealtimeEvent::ProjectUpdated { project_id: module.project_id }); Ok(Json(module)) @@ -55,13 +55,13 @@ pub(super) async fn update_module( State(db): State, Extension(realtime): Extension, Path(id): Path, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { let project_id = with_read(&db, |conn| { crate::db::queries::get_resource_project_id(conn, "modules", id) })?; - require_structure_role(&db, &auth_user, project_id)?; + require_structure_role(&db, &identity, project_id)?; let module = with_write(&db, |conn| { crate::db::queries::update_module(conn, id, &input) })?; @@ -73,12 +73,12 @@ pub(super) async fn delete_module_handler( State(db): State, Extension(realtime): Extension, Path(id): Path, - Extension(auth_user): Extension>, + Extension(identity): Extension>, ) -> Result, LificError> { let project_id = with_read(&db, |conn| { crate::db::queries::get_resource_project_id(conn, "modules", id) })?; - require_structure_role(&db, &auth_user, project_id)?; + require_structure_role(&db, &identity, project_id)?; with_write(&db, |conn| crate::db::queries::delete_module(conn, id))?; realtime.send(RealtimeEvent::ProjectUpdated { project_id }); Ok(Json(serde_json::json!({"deleted": true}))) @@ -93,10 +93,10 @@ pub(super) struct LabelQuery { pub(super) async fn list_labels( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Query(q): Query, ) -> Result>, LificError> { - authz::require_role(&db, &auth_user, q.project_id, Role::Viewer)?; + authz::require_role(&db, &identity, q.project_id, Role::Viewer)?; with_read(&db, |conn| { crate::db::queries::list_labels(conn, q.project_id) }) @@ -106,10 +106,10 @@ pub(super) async fn list_labels( pub(super) async fn create_label( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { - require_structure_role(&db, &auth_user, input.project_id)?; + require_structure_role(&db, &identity, input.project_id)?; let label = with_write(&db, |conn| crate::db::queries::create_label(conn, &input))?; realtime.send(RealtimeEvent::ProjectUpdated { project_id: input.project_id }); Ok(Json(label)) @@ -119,13 +119,13 @@ pub(super) async fn update_label_handler( State(db): State, Extension(realtime): Extension, Path(id): Path, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { let project_id = with_read(&db, |conn| { crate::db::queries::get_resource_project_id(conn, "labels", id) })?; - require_structure_role(&db, &auth_user, project_id)?; + require_structure_role(&db, &identity, project_id)?; let label = with_write(&db, |conn| { crate::db::queries::update_label(conn, id, &input) })?; @@ -137,12 +137,12 @@ pub(super) async fn delete_label_handler( State(db): State, Extension(realtime): Extension, Path(id): Path, - Extension(auth_user): Extension>, + Extension(identity): Extension>, ) -> Result, LificError> { let project_id = with_read(&db, |conn| { crate::db::queries::get_resource_project_id(conn, "labels", id) })?; - require_structure_role(&db, &auth_user, project_id)?; + require_structure_role(&db, &identity, project_id)?; with_write(&db, |conn| crate::db::queries::delete_label(conn, id))?; realtime.send(RealtimeEvent::ProjectUpdated { project_id }); Ok(Json(serde_json::json!({"deleted": true}))) @@ -158,7 +158,7 @@ pub(super) async fn merge_label_handler( State(db): State, Extension(realtime): Extension, Path(id): Path, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { // Both labels must live in the same project, and the caller must lead it. @@ -173,7 +173,7 @@ pub(super) async fn merge_label_handler( "cannot merge labels across projects".into(), )); } - require_structure_role(&db, &auth_user, source_project)?; + require_structure_role(&db, &identity, source_project)?; let label = with_write(&db, |conn| { crate::db::queries::merge_label(conn, id, input.into) })?; @@ -190,10 +190,10 @@ pub(super) struct FolderQuery { pub(super) async fn list_folders_handler( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Query(q): Query, ) -> Result>, LificError> { - authz::require_role(&db, &auth_user, q.project_id, Role::Viewer)?; + authz::require_role(&db, &identity, q.project_id, Role::Viewer)?; with_read(&db, |conn| { crate::db::queries::list_folders(conn, q.project_id) }) @@ -203,10 +203,10 @@ pub(super) async fn list_folders_handler( pub(super) async fn create_folder( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { - require_structure_role(&db, &auth_user, input.project_id)?; + require_structure_role(&db, &identity, input.project_id)?; let folder = with_write(&db, |conn| crate::db::queries::create_folder(conn, &input))?; realtime.send(RealtimeEvent::ProjectUpdated { project_id: input.project_id }); Ok(Json(folder)) @@ -216,12 +216,12 @@ pub(super) async fn delete_folder_handler( State(db): State, Extension(realtime): Extension, Path(id): Path, - Extension(auth_user): Extension>, + Extension(identity): Extension>, ) -> Result, LificError> { let project_id = with_read(&db, |conn| { crate::db::queries::get_resource_project_id(conn, "folders", id) })?; - require_structure_role(&db, &auth_user, project_id)?; + require_structure_role(&db, &identity, project_id)?; with_write(&db, |conn| crate::db::queries::delete_folder(conn, id))?; realtime.send(RealtimeEvent::ProjectUpdated { project_id }); Ok(Json(serde_json::json!({"deleted": true}))) @@ -231,13 +231,13 @@ pub(super) async fn update_folder( State(db): State, Extension(realtime): Extension, Path(id): Path, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Json(input): Json, ) -> Result, LificError> { let project_id = with_read(&db, |conn| { crate::db::queries::get_resource_project_id(conn, "folders", id) })?; - require_structure_role(&db, &auth_user, project_id)?; + require_structure_role(&db, &identity, project_id)?; let folder = with_write(&db, |conn| { crate::db::queries::update_folder(conn, id, &input) })?; diff --git a/src/api/views.rs b/src/api/views.rs index 8e753fa7..e4ad4b69 100644 --- a/src/api/views.rs +++ b/src/api/views.rs @@ -42,20 +42,24 @@ use super::{with_read, with_write}; /// saved views are inherently per-user, so there is no sensible "anonymous /// owner" to attribute a view to. Every handler below requires a resolved /// user on top of the role gate. -fn require_user(auth_user: Option) -> Result { - auth_user.ok_or_else(|| { - LificError::Forbidden("authentication required to manage saved views".into()) - }) +fn require_user( + identity: Option, +) -> Result { + identity + .map(|i| i.user) + .ok_or_else(|| { + LificError::Forbidden("authentication required to manage saved views".into()) + }) } /// GET /api/projects/{id}/views — the caller's own views only. pub(super) async fn list_views( State(db): State, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(project_id): Path, ) -> Result>, LificError> { - authz::require_role(&db, &auth_user, project_id, Role::Viewer)?; - let user = require_user(auth_user)?; + authz::require_role(&db, &identity, project_id, Role::Viewer)?; + let user = require_user(identity)?; with_read(&db, |conn| views::list_views(conn, project_id, user.id)).map(Json) } @@ -67,12 +71,12 @@ pub(super) async fn list_views( pub(super) async fn create_view( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path(project_id): Path, Json(input): Json, ) -> Result, LificError> { - authz::require_role(&db, &auth_user, project_id, Role::Viewer)?; - let user = require_user(auth_user)?; + authz::require_role(&db, &identity, project_id, Role::Viewer)?; + let user = require_user(identity)?; let view = with_write(&db, |conn| { views::create_view(conn, project_id, user.id, &input) })?; @@ -87,12 +91,12 @@ pub(super) async fn create_view( pub(super) async fn update_view( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path((project_id, view_id)): Path<(i64, i64)>, Json(input): Json, ) -> Result, LificError> { - authz::require_role(&db, &auth_user, project_id, Role::Viewer)?; - let user = require_user(auth_user)?; + authz::require_role(&db, &identity, project_id, Role::Viewer)?; + let user = require_user(identity)?; let view = with_write(&db, |conn| { views::update_view(conn, view_id, project_id, user.id, &input) })?; @@ -104,11 +108,11 @@ pub(super) async fn update_view( pub(super) async fn delete_view( State(db): State, Extension(realtime): Extension, - Extension(auth_user): Extension>, + Extension(identity): Extension>, Path((project_id, view_id)): Path<(i64, i64)>, ) -> Result, LificError> { - authz::require_role(&db, &auth_user, project_id, Role::Viewer)?; - let user = require_user(auth_user)?; + authz::require_role(&db, &identity, project_id, Role::Viewer)?; + let user = require_user(identity)?; with_write(&db, |conn| { views::delete_view(conn, view_id, project_id, user.id) })?; diff --git a/src/auth.rs b/src/auth.rs index 0eafab3d..0bfe349b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -8,9 +8,10 @@ use axum::{ use rusqlite::params; use tracing::{info, warn}; -use api_keys_simplified::{ApiKeyManagerV0, Environment, ExposeSecret, KeyStatus, SecureString}; +use api_keys_simplified::{ApiKeyManagerV0, Environment, ExposeSecret, KeyStatus}; use crate::db::DbPool; +use crate::db::models::AuthUser; #[derive(Clone)] pub struct AuthState { @@ -164,6 +165,29 @@ pub fn has_any_keys(db: &DbPool) -> bool { } } +/// Whether a first human (non-bot) operator exists yet. +pub fn has_human_operator(db: &DbPool) -> bool { + if let Ok(conn) = db.read() { + crate::db::queries::users::has_human_users(&conn).unwrap_or(false) + } else { + false + } +} + +/// LIFIC-9: whether to auto-mint the "default" unbound API key at startup. +/// +/// This is the single decision both `lific init` and `lific start` share. Under +/// the new design a human operator is created at `init` (passwordless mode), so +/// an unbound operator-style key should no longer be minted as the default path +/// — the operator *is* a real user now. The "default" key is still available on +/// demand via `lific key create`. We mint it only for the genuinely empty +/// bootstrap (no users at all, no keys) so a headless/agent-first install can +/// still get a credential before any human exists — and once a human exists we +/// never auto-mint it again, even if all keys were later revoked. +pub fn should_mint_initial_key(db: &DbPool) -> bool { + !has_any_keys(db) && !has_human_operator(db) +} + #[derive(Debug)] #[allow(dead_code)] pub struct ApiKeyInfo { @@ -208,6 +232,36 @@ fn is_attachment_download(method: &Method, path: &str) -> bool { !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()) } +/// LIFIC-8: resolve the caller's identity and stamp it into the request +/// extension *alongside* the legacy `Option`. Best-effort by design +/// during the expand step — a resolve failure (DB fault, read-lock poisoning) +/// is logged and degrades to `None` so auth itself never breaks. LIFIC-10 +/// will make the downstream gates read this; until then nothing consumes it. +/// +/// `default` is the transport the credential-type implies when the request +/// is NOT aimed at `/mcp` (session → web, key/oauth → api). +fn insert_resolved_identity( + request: &mut Request, + db: &DbPool, + credential_user: Option, + is_mcp_request: bool, + default: crate::actor::Transport, +) { + let transport = if is_mcp_request { + crate::actor::Transport::Mcp + } else { + default + }; + let resolved = match crate::resolve_caller::resolve_caller(db, credential_user, transport) { + Ok(id) => id, + Err(e) => { + warn!(error = %e, "resolved-identity lookup failed; degrading to None"); + None + } + }; + request.extensions_mut().insert(resolved); +} + /// Axum middleware that validates Bearer tokens and resolves user identity. /// /// After successful auth, inserts `Extension>` into the request: @@ -289,6 +343,13 @@ pub async fn require_api_key( user_id: Some(auth_user.id), transport: crate::actor::Transport::Web, }; + insert_resolved_identity( + &mut request, + &auth.db, + Some(auth_user.clone()), + false, + crate::actor::Transport::Web, + ); request.extensions_mut().insert(Some(auth_user)); return crate::actor::scope(actor, next.run(request)).await; } @@ -302,23 +363,23 @@ pub async fn require_api_key( // config surfaces as an error instead of silently degrading to // anonymous-with-admin-powers. if !auth.required { + let default = if is_mcp_request { + crate::actor::Transport::Mcp + } else { + crate::actor::Transport::Api + }; let actor = crate::actor::ActorCtx { user_id: None, - transport: if is_mcp_request { - crate::actor::Transport::Mcp - } else { - crate::actor::Transport::Api - }, + transport: default, }; + // LIFIC-8: resolve the passwordless identity (first-admin + // fallback). resolve_caller handles the operator bypass — no + // separate carrier needed (LIFIC-14 deleted the last of them). + insert_resolved_identity(&mut request, &auth.db, None, is_mcp_request, default); request .extensions_mut() .insert(Option::::None); - request.extensions_mut().insert(OperatorCredential); - return crate::actor::scope( - actor, - crate::authz::operator_scope(true, next.run(request)), - ) - .await; + return crate::actor::scope(actor, next.run(request)).await; } if is_mcp_request { @@ -362,6 +423,13 @@ pub async fn require_api_key( crate::actor::Transport::Web }, }; + insert_resolved_identity( + &mut request, + &auth.db, + Some(auth_user.clone()), + is_mcp_request, + crate::actor::Transport::Web, + ); request.extensions_mut().insert(Some(auth_user)); return crate::actor::scope(actor, next.run(request)).await; } @@ -406,6 +474,13 @@ pub async fn require_api_key( crate::actor::Transport::Api }, }; + insert_resolved_identity( + &mut request, + &auth.db, + auth_user.clone(), + is_mcp_request, + crate::actor::Transport::Api, + ); request.extensions_mut().insert(auth_user); return crate::actor::scope(actor, next.run(request)).await; } @@ -421,31 +496,109 @@ pub async fn require_api_key( } // ── API keys (lific_sk- prefix) ────────────────────────────── - let secure_token = SecureString::from(token); - - // Fast checksum pre-check: reject malformed keys in ~20μs without touching DB - match auth.manager.verify_checksum(&secure_token) { - Ok(true) => {} // valid checksum, proceed to DB lookup - _ => { + // LIFIC-18: shared with the stdio LIFIC_TOKEN resolver so the + // checksum/lookup/backfill/hash logic lives in one place (see + // `validate_api_key` just below `ApiKeyRow`). + let auth_user = match validate_api_key(&auth.db, &auth.manager, &token) { + Ok(user) => user, + Err(ApiKeyReject::BadChecksum) => { warn!("rejected API key with invalid checksum"); - return ( - StatusCode::UNAUTHORIZED, - [("WWW-Authenticate", www_auth.as_str())], - "Invalid API key", - ) - .into_response(); + return (StatusCode::UNAUTHORIZED, + [("WWW-Authenticate", www_auth.as_str())], "Invalid API key").into_response(); + } + Err(ApiKeyReject::Db) => { + return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response(); } + Err(ApiKeyReject::NotFound) => { + warn!("rejected invalid API key"); + return (StatusCode::UNAUTHORIZED, + [("WWW-Authenticate", www_auth.as_str())], "Invalid API key").into_response(); + } + Err(ApiKeyReject::HashMismatch) => { + warn!("API key hash verification failed"); + return (StatusCode::UNAUTHORIZED, + [("WWW-Authenticate", www_auth.as_str())], "Invalid API key").into_response(); + } + }; + + // LIF-155: API keys are programmatic — 'mcp' on the /mcp path, 'api' for + // direct REST usage. The LIF-261 operator bypass for an unbound key + // (user_id = None) now lives entirely in `resolve_caller` (inserted above), + // which falls back to the first admin — read as `identity.user.is_admin` by + // the gates. The old credential-type-specific `OperatorCredential` marker + // and `operator_scope` task-local are gone (LIFIC-14). + let actor = crate::actor::ActorCtx { + user_id: auth_user.as_ref().map(|u| u.id), + transport: if is_mcp_request { + crate::actor::Transport::Mcp + } else { + crate::actor::Transport::Api + }, + }; + insert_resolved_identity( + &mut request, + &auth.db, + auth_user.clone(), + is_mcp_request, + crate::actor::Transport::Api, + ); + request.extensions_mut().insert(auth_user); + crate::actor::scope(actor, next.run(request)).await +} + +/// Internal struct for loading API key rows during auth. +#[derive(Debug)] +struct ApiKeyRow { + #[allow(dead_code)] + id: i64, + hash: String, + user_id: Option, +} + +/// Why an API key failed to authenticate. Maps to both the HTTP response and +/// the stdio-resolver error, so both callers share one decision. +#[derive(Debug, Clone, Copy)] +enum ApiKeyReject { + /// A database read/write failed (backend fault, not a bad key). + Db, + /// The key didn't pass the format checksum. + BadChecksum, + /// The key is well-formed but matches no active key. + NotFound, + /// A matching key exists but the stored hash didn't verify. + HashMismatch, +} + +/// Shared API-key authentication for both the HTTP middleware and the stdio +/// `LIFIC_TOKEN` resolver. Verifies the checksum, resolves the key row by +/// derived key_id (with the pre-migration-010 scan-and-backfill fallback), and +/// verifies the stored hash — exactly one copy of that logic (LIFIC-18 review: +/// previously duplicated between `require_api_key` and `resolve_api_key_user`). +/// +/// Returns `Ok(Some(user))` for a valid bound key, `Ok(None)` for a valid but +/// unbound key (the caller falls that back to the operator), and +/// `Err(reject)` when the key does not authenticate. +fn validate_api_key( + db: &DbPool, + manager: &ApiKeyManagerV0, + token: &str, +) -> Result, ApiKeyReject> { + use api_keys_simplified::SecureString; + + let secure_token = SecureString::from(token.to_string()); + + // Fast checksum pre-check: reject malformed keys in ~20μs without touching DB. + match manager.verify_checksum(&secure_token) { + Ok(true) => {} + _ => return Err(ApiKeyReject::BadChecksum), } - // Compute deterministic key ID (BLAKE3, ~microseconds) for O(1) DB lookup - let key_id = auth.manager.extract_key_id(&secure_token); + // Compute deterministic key ID (BLAKE3, ~microseconds) for O(1) DB lookup. + let key_id = manager.extract_key_id(&secure_token); - // Look up the single matching key by key_id (indexed query) + // Look up the single matching key by key_id (indexed query). let key_row: Option = { - let conn = match auth.db.read() { - Ok(c) => c, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response(), - }; + let conn = db.read().map_err(|_| ApiKeyReject::Db)?; conn.query_row( "SELECT id, key_hash, user_id FROM api_keys WHERE key_id = ?1 AND revoked = 0 \ AND (expires_at IS NULL OR expires_at > datetime('now'))", @@ -461,9 +614,9 @@ pub async fn require_api_key( .ok() }; - // Fallback: keys created before migration 010 have no key_id — scan those + // Fallback: keys created before migration 010 have no key_id — scan those. let key_row = key_row.or_else(|| { - let conn = auth.db.read().ok()?; + let conn = db.read().ok()?; let mut stmt = conn .prepare( "SELECT id, key_hash, user_id FROM api_keys WHERE key_id IS NULL AND revoked = 0 \ @@ -481,11 +634,10 @@ pub async fn require_api_key( .ok()? .filter_map(|r| r.ok()) .collect(); - for row in rows { - if let Ok(KeyStatus::Valid) = auth.manager.verify(&secure_token, &row.hash) { - // Backfill the key_id so future lookups are O(1) - if let Ok(wconn) = auth.db.write() { + if let Ok(KeyStatus::Valid) = manager.verify(&secure_token, &row.hash) { + // Backfill the key_id so future lookups are O(1). + if let Ok(wconn) = db.write() { let _ = wconn.execute( "UPDATE api_keys SET key_id = ?1 WHERE id = ?2", params![key_id, row.id], @@ -498,21 +650,16 @@ pub async fn require_api_key( }); let Some(key) = key_row else { - warn!("rejected invalid API key"); - return ( - StatusCode::UNAUTHORIZED, - [("WWW-Authenticate", www_auth.as_str())], - "Invalid API key", - ) - .into_response(); + return Err(ApiKeyReject::NotFound); }; - // Verify the key against the stored Argon2 hash - match auth.manager.verify(&secure_token, &key.hash) { + match manager.verify(&secure_token, &key.hash) { Ok(KeyStatus::Valid) => { - // Resolve user if the key has a user_id + // Resolve the user if the key has a user_id. A valid-but-unbound + // key (legacy, or a fresh-install unassigned key) is Ok(None) — the + // caller falls back to the operator. let auth_user = key.user_id.and_then(|uid| { - let conn = auth.db.read().ok()?; + let conn = db.read().ok()?; crate::db::queries::users::get_user_by_id(&conn, uid) .ok() .map(|u| crate::db::models::AuthUser { @@ -522,65 +669,50 @@ pub async fn require_api_key( is_admin: u.is_admin, }) }); - // LIF-261: an API key with NO user binding is operator-trusted — - // it can only be minted with shell access to the server, so it's - // admin-equivalent in enforced mode. This is the ONE credential - // path that sets the operator signal; OAuth/session tokens never - // do, so a legacy unbound OAuth token (also `AuthUser = None`) - // stays default-denied. Keyed off the DB binding, not the resolved - // `auth_user`, so a key bound to a since-deleted user does NOT - // silently become an operator. - let is_operator = key.user_id.is_none(); - // LIF-155: API keys are programmatic — 'mcp' on the /mcp - // path, 'api' for direct REST usage. - let actor = crate::actor::ActorCtx { - user_id: auth_user.as_ref().map(|u| u.id), - transport: if is_mcp_request { - crate::actor::Transport::Mcp - } else { - crate::actor::Transport::Api - }, - }; - request.extensions_mut().insert(auth_user); - // The /mcp route reads this marker to pass the operator flag into - // `with_request_identity`; REST reads the task-local scoped below. - if is_operator { - request.extensions_mut().insert(OperatorCredential); - } - crate::actor::scope( - actor, - crate::authz::operator_scope(is_operator, next.run(request)), - ) - .await - } - _ => { - warn!("API key hash verification failed"); - ( - StatusCode::UNAUTHORIZED, - [("WWW-Authenticate", www_auth.as_str())], - "Invalid API key", - ) - .into_response() + Ok(auth_user) } + _ => Err(ApiKeyReject::HashMismatch), } } -/// LIF-261: request-extension marker inserted by [`require_api_key`] when the -/// authenticated credential is an operator-trusted unbound API key. The `/mcp` -/// route reads it (via `request.extensions().get::()`) to -/// forward the operator flag into `mcp::with_request_identity`. REST handlers -/// don't read it — they see the operator signal through the task-local scoped -/// by `authz::operator_scope` around the same request. -#[derive(Clone, Copy)] -pub struct OperatorCredential; +/// LIFIC-18: resolve an API key (e.g. the `LIFIC_TOKEN` carried by a stdio +/// agent) to its bound user, without an HTTP request context. +/// +/// Returns `Some(user)` when the key is valid AND bound to a user; `Ok(None)` +/// for a valid-but-unbound key (the stdio session then falls back to the +/// operator). An invalid/unrecognized key is an error so the caller can warn +/// loudly and still degrade to the operator fallback. +pub fn resolve_api_key_user( + db: &DbPool, + manager: &ApiKeyManagerV0, + token: &str, +) -> Result, String> { + // Reuse the shared validator (same checksum/lookup/backfill/hash logic the + // HTTP middleware runs). Mapping the typed rejection to a human string + // keeps the stdio resolver vendoring nothing of its own. + validate_api_key(db, manager, token).map_err(|reject| match reject { + ApiKeyReject::Db => "database error".to_string(), + ApiKeyReject::BadChecksum => "invalid API key checksum".to_string(), + ApiKeyReject::NotFound => "invalid API key".to_string(), + ApiKeyReject::HashMismatch => "API key hash verification failed".to_string(), + }) +} -/// Internal struct for loading API key rows during auth. -#[derive(Debug)] -struct ApiKeyRow { - #[allow(dead_code)] - id: i64, - hash: String, - user_id: Option, +/// LIFIC-18: resolve the `LIFIC_TOKEN` a stdio agent carries into its bound +/// user. `Ok(None)` when the token is absent, empty, or valid-but-unbound — +/// the session runs as the operator. `Err` when a token was present but +/// invalid (checksum/DB/hash failure), so the stdio entrypoint can emit a +/// distinct warning while still degrading to the operator fallback. +pub fn resolve_stdio_token( + db: &DbPool, + manager: &ApiKeyManagerV0, +) -> Result, String> { + let raw = std::env::var("LIFIC_TOKEN").unwrap_or_default(); + let token = raw.trim(); + if token.is_empty() { + return Ok(None); + } + resolve_api_key_user(db, manager, token) } #[cfg(test)] @@ -596,6 +728,11 @@ mod tests { db::open_memory().expect("test db") } + // Serializes env mutation (LIFIC_TOKEN) across the stdio-token tests — + // the process env is global, so every test that touches it must share one + // lock, not declare its own. + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] fn create_key_returns_valid_format() { let pool = test_db(); @@ -759,6 +896,32 @@ mod tests { assert!(has_any_keys(&pool)); } + // LIFIC-9: the initial-key decision is shared by `init` and `start`. + #[test] + fn should_mint_initial_key_empty_bootstrap_mints() { + let pool = test_db(); + // No humans, no keys: the genuinely empty bootstrap. + assert!(should_mint_initial_key(&pool)); + } + + #[test] + fn should_mint_initial_key_false_once_a_human_operator_exists() { + let pool = test_db(); + let conn = pool.write().unwrap(); + crate::db::queries::users::create_passwordless_admin(&conn, "Blake").unwrap(); + drop(conn); + // A human exists even with zero keys: passwordless mode, no mint. + assert!(!should_mint_initial_key(&pool)); + } + + #[test] + fn should_mint_initial_key_false_when_any_key_exists() { + let pool = test_db(); + let manager = create_key_manager().unwrap(); + create_api_key(&pool, &manager, "first").unwrap(); + assert!(!should_mint_initial_key(&pool)); + } + #[test] fn create_key_stores_key_id() { let pool = test_db(); @@ -941,6 +1104,49 @@ mod tests { ); } + #[tokio::test] + async fn oauth_token_bound_to_bot_resolves_to_the_bot_identity() { + // LIFIC-13: at OAuth approval Lific mints a per-tool bot and binds the + // issued token to it. The middleware must resolve that token to the bot + // (which authz then raises to the bot's owner for permissions). + let pool = test_db(); + let bot_id = { + let conn = pool.write().unwrap(); + crate::db::queries::users::create_user( + &conn, + &crate::db::models::CreateUser { + username: "claude-code-blake".into(), + email: "claude-code-blake@bot.local".into(), + password: "testpassword1".into(), + display_name: Some("Claude Code".into()), + is_admin: false, + is_bot: true, + }, + ) + .unwrap() + .id + }; + let token = insert_oauth_token(&pool, "bot", Some(bot_id)); + + let resp = identity_echo_app(test_auth_state(&pool)) + .oneshot( + Request::builder() + .uri("/echo") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let body = String::from_utf8(bytes.as_ref().to_vec()).unwrap(); + assert!( + body.contains(&format!("id:{bot_id}:claude-code-blake")), + "OAuth token must resolve to the per-tool bot, got: {body}" + ); + } + #[tokio::test] async fn legacy_api_key_without_user_resolves_to_none_via_middleware() { let pool = test_db(); @@ -976,15 +1182,19 @@ mod tests { Router::new() .route( "/probe", - get(move || { - let pool = pool.clone(); - async move { - match crate::authz::visible_project_ids(&pool, &None).unwrap() { - None => "unrestricted".to_string(), - Some(ids) => format!("restricted:{}", ids.len()), + get( + move |Extension(identity): Extension< + Option, + >| { + let pool = pool.clone(); + async move { + match crate::authz::visible_project_ids(&pool, &identity).unwrap() { + None => "unrestricted".to_string(), + Some(ids) => format!("restricted:{}", ids.len()), + } } - } - }), + }, + ), ) .layer(middleware::from_fn_with_state(auth_state, require_api_key)) } @@ -992,6 +1202,7 @@ mod tests { #[tokio::test] async fn auth_not_required_credentialless_request_passes_as_operator() { let pool = test_db(); + seed_admin(&pool, "admin"); // resolve_caller needs a first_admin to resolve to enable_enforcement(&pool); let mut state = test_auth_state(&pool); state.required = false; @@ -1253,14 +1464,14 @@ mod tests { assert_eq!(stored.as_deref(), Some("2030-06-01")); } - // ── LIF-261: operator-key trust rule, end-to-end through the middleware ── + // ── LIF-261 / LIFIC-7: operator-key trust rule, end-to-end through the middleware ── // - // The auth middleware sets `authz::operator_scope(true, ..)` ONLY on the - // unbound-API-key path. These drive a real route that runs + // resolve_caller maps any credential that authenticates but resolves no + // user (unbound API key, legacy unbound OAuth token, "auth off" request) + // to the first admin — so an unbound key passes the gate via + // `identity.user.is_admin`. These drive a real route that runs // `authz::require_role(.., Viewer)` in enforced mode behind the real - // `require_api_key`, so a 200 means the gate passed and a 403 means it - // denied — proving the credential-type signal reaches authz and that a - // legacy unbound OAuth token (also `None`) does NOT get the bypass. + // `require_api_key`: a 200 means the gate passed, a 403 means it denied. fn enable_enforcement(pool: &db::DbPool) { let conn = pool.write().unwrap(); @@ -1295,11 +1506,11 @@ mod tests { fn gate_app(auth_state: AuthState, pool: db::DbPool, project_id: i64) -> Router { async fn gate( State((pool, project_id)): State<(db::DbPool, i64)>, - Extension(auth_user): Extension>, + Extension(identity): Extension>, ) -> Result { crate::authz::require_role( &pool, - &auth_user, + &identity, project_id, crate::db::models::Role::Viewer, )?; @@ -1327,6 +1538,7 @@ mod tests { #[tokio::test] async fn enforced_operator_unbound_key_passes_viewer_gate_via_middleware() { let pool = test_db(); + seed_admin(&pool, "admin"); // resolve_caller needs a first_admin to resolve to let manager = create_key_manager().unwrap(); let key = create_api_key(&pool, &manager, "operator").unwrap(); // unbound let project = seed_project_id(&pool, "OPM"); @@ -1349,13 +1561,17 @@ mod tests { let project = seed_project_id(&pool, "OAM"); enable_enforcement(&pool); // Unbound OAuth token (user_id = None) — the LIF-204 legacy case. + // No admin is seeded, so resolve_caller returns None and the enforced + // gate default-denies. (With an admin present, resolve_caller would + // resolve it to first_admin — the operator bypass is "the first admin + // is trusted," not credential-type-specific.) let token = insert_oauth_token(&pool, "legacy-unbound", None); let app = gate_app(test_auth_state(&pool), pool.clone(), project); assert_eq!( gate_status(app, &token).await, StatusCode::FORBIDDEN, - "a legacy unbound OAuth token must NOT gain operator power — it stays default-denied" + "with no admin to resolve to, a credential-less request stays default-denied" ); } @@ -1497,4 +1713,320 @@ mod tests { headers.insert("cookie", "other=1; another=2".parse().unwrap()); assert_eq!(session_cookie_token(&headers), None); } + + // ── LIFIC-8: middleware inserts ResolvedIdentity alongside Option ── + // + // `require_api_key` now also inserts `Extension` (when a + // user can be resolved) at every success branch. These drive the real + // middleware through a handler that echoes the identity back, asserting + // the resolved user AND the transport for each credential type — including + // the first-admin passwordless fallback for the credential-less and + // unbound-credential paths. + + fn seed_admin(pool: &db::DbPool, username: &str) -> i64 { + let conn = pool.write().unwrap(); + let u = crate::db::queries::users::create_user( + &conn, + &crate::db::models::CreateUser { + username: username.into(), + email: format!("{username}@local.test"), + password: "adminpass123".into(), + display_name: Some(format!("Admin {username}")), + is_admin: true, + is_bot: false, + }, + ) + .unwrap(); + u.id + } + + /// Echo handler that reports the `ResolvedIdentity` the middleware + /// resolved, mirroring `echo_app` but for the new identity type. + fn identity_echo_app(auth_state: AuthState) -> Router { + async fn echo( + Extension(identity): Extension>, + ) -> String { + match identity { + Some(id) => format!( + "id:{}:{}:{}:{}", + id.user.id, + id.user.username, + id.user.is_admin, + id.transport.as_str() + ), + None => "none".to_string(), + } + } + Router::new() + .route("/echo", get(echo)) + .layer(middleware::from_fn_with_state(auth_state, require_api_key)) + } + + async fn identity_body(app: Router, auth: Option<&str>) -> String { + let mut req = Request::builder().uri("/echo"); + if let Some(token) = auth { + req = req.header("authorization", format!("Bearer {token}")); + } + let resp = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + String::from_utf8(bytes.as_ref().to_vec()).unwrap() + } + + #[tokio::test] + async fn resolved_identity_session_token_is_the_user_on_web_transport() { + let pool = test_db(); + let (token, user_id) = { + let conn = pool.write().unwrap(); + let u = crate::db::queries::users::create_user( + &conn, + &crate::db::models::CreateUser { + username: "sessuser".into(), + email: "sessuser@test.com".into(), + password: "testpassword1".into(), + display_name: None, + is_admin: false, + is_bot: false, + }, + ) + .unwrap(); + let token = crate::db::queries::users::create_session(&conn, u.id, None) + .unwrap() + .token; + (token, u.id) + }; + + let body = identity_body(identity_echo_app(test_auth_state(&pool)), Some(&token)).await; + assert_eq!( + body, + format!("id:{user_id}:sessuser:false:web"), + "session token must resolve to its user on the web transport" + ); + } + + #[tokio::test] + async fn resolved_identity_bound_api_key_resolves_to_bound_user() { + let pool = test_db(); + let manager = create_key_manager().unwrap(); + let key = create_api_key(&pool, &manager, "bound").unwrap(); + let user_id = { + let conn = pool.write().unwrap(); + let u = crate::db::queries::users::create_user( + &conn, + &crate::db::models::CreateUser { + username: "keyuser".into(), + email: "keyuser@test.com".into(), + password: "testpassword1".into(), + display_name: None, + is_admin: false, + is_bot: false, + }, + ) + .unwrap(); + crate::db::queries::users::assign_key_to_user(&conn, "bound", u.id).unwrap(); + u.id + }; + + let body = identity_body(identity_echo_app(test_auth_state(&pool)), Some(&key)).await; + assert_eq!( + body, + format!("id:{user_id}:keyuser:false:api"), + "a user-bound API key must resolve to that user on the api transport" + ); + } + + #[tokio::test] + async fn resolved_identity_bound_oauth_token_resolves_to_bound_user() { + let pool = test_db(); + let user_id = { + let conn = pool.write().unwrap(); + crate::db::queries::users::create_user( + &conn, + &crate::db::models::CreateUser { + username: "oauthuser".into(), + email: "oauthuser@test.com".into(), + password: "testpassword1".into(), + display_name: None, + is_admin: false, + is_bot: false, + }, + ) + .unwrap() + .id + }; + let token = insert_oauth_token(&pool, "bound-oauth", Some(user_id)); + + let body = identity_body(identity_echo_app(test_auth_state(&pool)), Some(&token)).await; + assert_eq!( + body, + format!("id:{user_id}:oauthuser:false:api"), + "a user-bound OAuth token must resolve to that user on the api transport" + ); + } + + // The passwordless fallback: a legacy unbound OAuth token carries no user, + // so resolve_caller falls back to the first admin. The legacy + // Option stays None (proven by the existing middleware tests); + // only the new ResolvedIdentity sees the fallback user. + #[tokio::test] + async fn resolved_identity_legacy_unbound_oauth_falls_back_to_first_admin() { + let pool = test_db(); + let admin_id = seed_admin(&pool, "admin"); + let token = insert_oauth_token(&pool, "legacy-unbound-id", None); + + let body = identity_body(identity_echo_app(test_auth_state(&pool)), Some(&token)).await; + assert_eq!( + body, + format!("id:{admin_id}:admin:true:api"), + "a legacy unbound OAuth token must resolve to the first admin via the fallback" + ); + } + + // An operator-trusted unbound API key likewise resolves to the first admin + // in the new identity (its admin-ness comes from first_admin, not a + // separate operator flag). + #[tokio::test] + async fn resolved_identity_unbound_api_key_falls_back_to_first_admin() { + let pool = test_db(); + let admin_id = seed_admin(&pool, "admin"); + let manager = create_key_manager().unwrap(); + let key = create_api_key(&pool, &manager, "operator").unwrap(); // unbound + + let body = identity_body(identity_echo_app(test_auth_state(&pool)), Some(&key)).await; + assert_eq!( + body, + format!("id:{admin_id}:admin:true:api"), + "an unbound (operator) API key must resolve to the first admin" + ); + } + + // Auth-off: a credential-less request is the passwordless case par + // excellence — resolve_caller supplies the first admin so identity is + // always known even with no credential presented. + #[tokio::test] + async fn resolved_identity_auth_off_credentialless_falls_back_to_first_admin() { + let pool = test_db(); + let admin_id = seed_admin(&pool, "admin"); + let mut state = test_auth_state(&pool); + state.required = false; + + let body = identity_body(identity_echo_app(state), None).await; + assert_eq!( + body, + format!("id:{admin_id}:admin:true:api"), + "auth-off credential-less request must resolve to the first admin" + ); + } + + // The degenerate case: no credential AND no admin exists → no + // ResolvedIdentity is inserted. The legacy Option path is + // unchanged (the request still passes as operator-equivalent). + #[tokio::test] + async fn resolved_identity_auth_off_zero_users_inserts_no_identity() { + let pool = test_db(); // no users at all + let mut state = test_auth_state(&pool); + state.required = false; + + let body = identity_body(identity_echo_app(state), None).await; + assert_eq!(body, "none", "zero-user bootstrap inserts no identity"); + } + + // ── LIFIC-18: stdio token resolution (auth::resolve_stdio_token) ─────── + + #[test] + fn resolve_api_key_user_bound_key_returns_user() { + let pool = test_db(); + let manager = create_key_manager().unwrap(); + let uid = { + let conn = pool.write().unwrap(); + crate::db::queries::users::create_user( + &conn, + &crate::db::models::CreateUser { + username: "agent-user".into(), + email: "agent-user@local.test".into(), + password: "testpassword1".into(), + display_name: None, + is_admin: false, + is_bot: true, + }, + ) + .unwrap() + .id + }; + let key = create_api_key(&pool, &manager, "opencode-agent").unwrap(); + { + let conn = pool.write().unwrap(); + crate::db::queries::users::assign_key_to_user(&conn, "opencode-agent", uid).unwrap(); + } + let resolved = resolve_api_key_user(&pool, &manager, &key).unwrap(); + let user = resolved.expect("bound key resolves to a user"); + assert_eq!(user.id, uid); + assert_eq!(user.username, "agent-user"); + } + + #[test] + fn resolve_api_key_user_unbound_key_is_ok_none() { + let pool = test_db(); + let manager = create_key_manager().unwrap(); + let key = create_api_key(&pool, &manager, "unbound").unwrap(); + let resolved = resolve_api_key_user(&pool, &manager, &key).unwrap(); + assert!( + resolved.is_none(), + "a valid-but-unbound key must be Ok(None) — operator fallback" + ); + } + + #[test] + fn resolve_api_key_user_invalid_key_is_err() { + let pool = test_db(); + let manager = create_key_manager().unwrap(); + let err = resolve_api_key_user(&pool, &manager, "lific_sk-live-NOTAREALKEY") + .expect_err("a bogus key must be an error"); + assert!(!err.is_empty()); + } + + #[test] + fn resolve_stdio_token_without_env_is_ok_none() { + // No LIFIC_TOKEN in the environment → Ok(None): the operator fallback. + let _guard = ENV_LOCK.lock().unwrap(); + let pool = test_db(); + let manager = create_key_manager().unwrap(); + // SAFETY: guarded by ENV_LOCK. + unsafe { std::env::remove_var("LIFIC_TOKEN") }; + let resolved = resolve_stdio_token(&pool, &manager).unwrap(); + assert!(resolved.is_none(), "absent token must be Ok(None)"); + } + + #[test] + fn resolve_stdio_token_valid_env_resolves_bound_user() { + let _guard = ENV_LOCK.lock().unwrap(); + let pool = test_db(); + let manager = create_key_manager().unwrap(); + let uid = { + let conn = pool.write().unwrap(); + crate::db::queries::users::create_user( + &conn, + &crate::db::models::CreateUser { + username: "stdio-agent".into(), + email: "stdio-agent@local.test".into(), + password: "testpassword1".into(), + display_name: None, + is_admin: false, + is_bot: true, + }, + ) + .unwrap() + .id + }; + let key = create_api_key(&pool, &manager, "codex-agent").unwrap(); + { + let conn = pool.write().unwrap(); + crate::db::queries::users::assign_key_to_user(&conn, "codex-agent", uid).unwrap(); + } + // SAFETY: guarded by ENV_LOCK; restored every path below. + unsafe { std::env::set_var("LIFIC_TOKEN", &key) }; + let resolved = resolve_stdio_token(&pool, &manager).unwrap(); + unsafe { std::env::remove_var("LIFIC_TOKEN") }; + assert_eq!(resolved.unwrap().id, uid); + } } diff --git a/src/authz.rs b/src/authz.rs index 1af0f539..fb92085b 100644 --- a/src/authz.rs +++ b/src/authz.rs @@ -29,7 +29,7 @@ //! owner (`007_bot_owners.sql`) before either check runs, in both modes — an //! agent can never exceed the human that owns it. //! -//! ## Operator-key trust (LIF-261) +//! ## Operator-key trust (LIF-261 → LIFIC-7/8/10/11/14) //! //! Enforced mode is default-deny for a `None` effective user. That alone would //! brick the zero-user agent-first flow (`lific init` → `start` → `connect`), @@ -40,14 +40,17 @@ //! guards against is a web-signup stranger with a session/OAuth token, not the //! operator's own shell-minted key. //! -//! So enforced mode treats an **unbound API key** as admin-equivalent. The -//! signal is credential-type-specific and comes only from the auth layer's -//! unbound-API-key path — it is deliberately NOT "any `None`," because a legacy -//! pre-binding OAuth token (`src/auth.rs`) also resolves to `None` and must -//! stay default-denied. The auth middleware sets [`operator_scope`] (REST) or -//! `mcp::with_request_user`'s operator flag (MCP); [`operator_context`] reads -//! whichever surface is active. **Unbound API keys therefore bypass authz by -//! design; audit them with `lific key list`.** +//! LIFIC-7 unified this on [`resolve_caller`]: any credential that +//! authenticates but resolves no specific user (an unbound API key, a legacy +//! unbound OAuth token, a credential-less "auth off" request, or a stdio MCP +//! session) falls back to the **first admin**. The gates then read +//! `identity.user.is_admin`, which short-circuits to allow — so the operator +//! bypass is just "the first admin is trusted," applied identically across +//! REST, MCP, and CLI. LIFIC-14 deleted the last credential-type-specific +//! operator carriers (`operator_context`, `operator_scope`, the `OPERATOR` +//! task-local, `OperatorCredential`, MCP's operator flag); the signal now +//! lives entirely in the resolved identity. **Audit unbound keys with `lific +//! key list`.** use std::collections::HashSet; @@ -56,34 +59,7 @@ use rusqlite::{Connection, OptionalExtension, params}; use crate::db::models::{AuthUser, Role}; use crate::db::{DbPool, queries}; use crate::error::LificError; - -// ── Operator-key trust signal (LIF-261) ───────────────────────── - -tokio::task_local! { - /// REST-side, request-scoped "the credential is an operator-trusted - /// unbound API key" flag. Set by `auth::require_api_key` via - /// [`operator_scope`] around the downstream handler, which runs in the - /// same task — so ambient reads in `require_role` see it. MCP can't use a - /// task-local (rmcp spawns internal tasks that drop it), so it mirrors the - /// flag into `mcp::current_is_operator()` instead. - static OPERATOR: bool; -} - -/// Run `fut` with the REST operator flag in scope. `auth::require_api_key` -/// wraps the unbound-API-key request path in this so `authz`'s ambient -/// [`operator_context`] reads `true` for the duration of the request. -pub async fn operator_scope(is_operator: bool, fut: F) -> F::Output { - OPERATOR.scope(is_operator, fut).await -} - -/// Whether the current request's credential is an operator-trusted unbound -/// API key. Checks the REST task-local first, then the MCP request global — -/// exactly one is ever set for a given request. Defaults to `false` -/// (including every synchronous / CLI / test context that sets neither), so -/// no code path silently gains operator power without an explicit signal. -fn operator_context() -> bool { - OPERATOR.try_with(|o| *o).unwrap_or(false) || crate::mcp::current_is_operator() -} +use crate::resolve_caller::ResolvedIdentity; // ── Bot → owner resolution ────────────────────────────────────── @@ -126,6 +102,15 @@ pub fn effective_user(conn: &Connection, auth_user: &Option) -> Option } } +/// LIFIC-10: extract the credential user from a resolved identity for the +/// bot→owner [`effective_user`] lookup. Gates now take `&Option` +/// (the type [`resolve_caller`] produces), but `effective_user` still operates +/// on `AuthUser` — its job (map a bot to its owner) is user-level, not +/// identity-level. This is the single adapter between the two. +fn user_of(identity: &Option) -> Option { + identity.as_ref().map(|i| i.user.clone()) +} + // ── Instance setting read ─────────────────────────────────────── fn authz_enforced_conn(conn: &Connection) -> Result { @@ -153,22 +138,28 @@ fn insufficient_role(min: Role) -> LificError { /// Require the effective caller to hold at least `min` role on `project_id`. /// `Ok(())` = allowed. See the module docs for the legacy-vs-enforced /// semantics; `is_admin` always short-circuits to allow in both modes. +/// +/// LIFIC-10/14: consumes [`ResolvedIdentity`] instead of `Option`. The +/// LIF-261 operator bypass is gone as a separate signal — an operator-trusted +/// unbound API key now resolves (via [`resolve_caller`]) to the first admin, so +/// `identity.user.is_admin` catches it in the same `is_admin` short-circuit +/// below. pub fn require_role( db: &DbPool, - auth_user: &Option, + identity: &Option, project_id: i64, min: Role, ) -> Result<(), LificError> { let conn = db.read()?; - require_role_conn(&conn, auth_user, project_id, min) + require_role_conn(&conn, identity, project_id, min) } pub(crate) fn can_view_project( db: &DbPool, - user: &AuthUser, + identity: &ResolvedIdentity, project_id: i64, ) -> Result { - if user.is_admin { + if identity.user.is_admin { return Ok(true); } let conn = db.read()?; @@ -176,47 +167,30 @@ pub(crate) fn can_view_project( return Ok(true); } Ok(matches!( - queries::members::get_member_role(&conn, project_id, user.id)?, + queries::members::get_member_role(&conn, project_id, identity.user.id)?, Some(role) if role >= Role::Viewer )) } fn require_role_conn( conn: &Connection, - auth_user: &Option, + identity: &Option, project_id: i64, min: Role, ) -> Result<(), LificError> { - require_role_conn_op(conn, auth_user, project_id, min, operator_context()) -} - -/// Same as [`require_role_conn`] but with the operator signal passed -/// explicitly, so tests can exercise both the operator and non-operator paths -/// deterministically without an ambient task-local / MCP global. -fn require_role_conn_op( - conn: &Connection, - auth_user: &Option, - project_id: i64, - min: Role, - is_operator: bool, -) -> Result<(), LificError> { - let effective = effective_user(conn, auth_user); + let auth_user = user_of(identity); + let effective = effective_user(conn, &auth_user); // Admin — resolved *after* bot→owner inheritance — always wins, in - // both modes. + // both modes. LIFIC-10: this is also where the operator bypass now + // lands — an unbound API key resolves to the first admin (see + // [`resolve_caller`]), so it's caught here rather than by a separate + // `is_operator` signal. if matches!(&effective, Some(u) if u.is_admin) { return Ok(()); } if authz_enforced_conn(conn)? { - // LIF-261: an operator-trusted unbound API key is admin-equivalent in - // enforced mode. This is gated on `is_operator` (a credential-type - // signal from the auth layer), NOT on `effective` being `None`, so a - // legacy unbound OAuth token — which is also `None` here — still falls - // through to the default-deny check below. - if is_operator { - return Ok(()); - } require_role_enforced(conn, &effective, project_id, min) } else { require_role_legacy(conn, &effective, project_id, min) @@ -304,13 +278,13 @@ fn require_lead_legacy( /// already does, unchanged. pub fn require_structure_role( db: &DbPool, - auth_user: &Option, + identity: &Option, project_id: i64, ) -> Result<(), LificError> { if authz_enforced(db)? { - require_role(db, auth_user, project_id, Role::Maintainer) + require_role(db, identity, project_id, Role::Maintainer) } else { - require_role(db, auth_user, project_id, Role::Lead) + require_role(db, identity, project_id, Role::Lead) } } @@ -324,16 +298,22 @@ pub fn require_structure_role( /// with the flag off, which never worked before. So legacy mode reproduces /// `require_admin` verbatim here (deliberately not `effective_user`-aware: /// the pre-existing check never resolved bot ownership either). +/// +/// LIFIC-10: consumes [`ResolvedIdentity`]. In legacy mode the check is now +/// `identity.user.is_admin` — so an unbound API key (which resolves to the +/// first admin) is admitted, where before it was `None` and denied. That is +/// the intended operator-works-everywhere fix (AC: "unbound keys resolve via +/// first_admin"), not a regression: real non-admin users are still denied. pub fn require_project_delete_role( db: &DbPool, - auth_user: &Option, + identity: &Option, project_id: i64, ) -> Result<(), LificError> { if authz_enforced(db)? { - require_role(db, auth_user, project_id, Role::Lead) + require_role(db, identity, project_id, Role::Lead) } else { - match auth_user { - Some(user) if user.is_admin => Ok(()), + match identity { + Some(i) if i.user.is_admin => Ok(()), _ => Err(LificError::Forbidden("only an admin can do this".into())), } } @@ -345,19 +325,19 @@ pub fn require_project_delete_role( /// #10: admin-only once enforcement is on. Legacy mode has never gated these /// at all (no `require_*` call existed on the pre-LIF-194 page handlers), so /// flag-off stays a no-op here to avoid a behavior change. +/// +/// LIFIC-10/14: consumes [`ResolvedIdentity`]; the operator bypass is now +/// `identity.user.is_admin` (an unbound key resolves to the first admin), +/// not a separate carrier signal. pub fn require_workspace_admin( db: &DbPool, - auth_user: &Option, + identity: &Option, ) -> Result<(), LificError> { if !authz_enforced(db)? { return Ok(()); } - // LIF-261: operator-trusted unbound API keys are admin-equivalent. - if operator_context() { - return Ok(()); - } let conn = db.read()?; - let effective = effective_user(&conn, auth_user); + let effective = effective_user(&conn, &user_of(identity)); match &effective { Some(u) if u.is_admin => Ok(()), _ => Err(LificError::Forbidden( @@ -372,30 +352,27 @@ pub fn require_workspace_admin( /// workspace-spanning read (LIF-197/LIF-198 call sites). /// /// `None` = unrestricted — caller should apply no filter at all. Returned -/// for admins, operator-trusted unbound API keys (LIF-261), and whenever -/// enforcement is off (legacy mode has no concept of hidden projects). -/// `Some(ids)` = only these project ids are visible: the effective caller's -/// memberships (any role), or the empty set for a `None` auth user / a member -/// of nothing. +/// for admins and whenever enforcement is off (legacy mode has no concept of +/// hidden projects). `Some(ids)` = only these project ids are visible: the +/// effective caller's memberships (any role), or the empty set for a `None` +/// auth user / a member of nothing. +/// +/// LIFIC-10/14: consumes [`ResolvedIdentity`]; the operator bypass is now +/// `identity.user.is_admin` (an unbound key resolves to the first admin and +/// short-circuits below). pub fn visible_project_ids( db: &DbPool, - auth_user: &Option, + identity: &Option, ) -> Result>, LificError> { let conn = db.read()?; - let effective = effective_user(&conn, auth_user); + let effective = effective_user(&conn, &user_of(identity)); if matches!(&effective, Some(u) if u.is_admin) { return Ok(None); } if !authz_enforced_conn(&conn)? { return Ok(None); } - // LIF-261: an operator-trusted unbound API key sees everything, like an - // admin. Gated on the credential-type signal, not on `effective` being - // `None` — a legacy unbound OAuth token stays scoped to the empty set. - if operator_context() { - return Ok(None); - } let Some(user) = effective else { return Ok(Some(HashSet::new())); }; @@ -409,11 +386,41 @@ pub fn visible_project_ids( #[cfg(test)] mod tests { use super::*; + use crate::actor::Transport; use crate::db::models::{CreateProject, CreateUser}; use crate::db::queries::members::upsert_member; use crate::db::queries::settings::{InstanceSettingsPatch, update as update_settings}; use crate::db::{self, queries}; + /// Wrap an `AuthUser` into a `ResolvedIdentity` (transport is irrelevant to + /// the gates — only `user` drives membership/admin checks). Mirrors what + /// [`crate::resolve_caller`] produces in production for a credential that + /// already names a user. + fn id(user: AuthUser) -> Option { + Some(ResolvedIdentity { + user, + transport: Transport::Api, + }) + } + + /// The identity an unbound API key resolves to under the new design: the + /// first admin, via [`crate::resolve_caller`]. Tests use this to prove the + /// operator bypass now lives in `identity.user.is_admin` (LIFIC-10) rather + /// than a separate `is_operator` signal. + fn first_admin_identity(conn: &Connection) -> Option { + queries::users::first_admin(conn) + .unwrap() + .map(|admin| ResolvedIdentity { + user: AuthUser { + id: admin.id, + username: admin.username, + display_name: admin.display_name, + is_admin: admin.is_admin, + }, + transport: Transport::Api, + }) + } + fn test_db() -> DbPool { db::open_memory().expect("test db") } @@ -504,7 +511,7 @@ mod tests { let outsider = seed_user(&conn, "outsider", false); for min in [Role::Viewer, Role::Maintainer, Role::Lead] { - let err = require_role_conn(&conn, &Some(outsider.clone()), project, min).unwrap_err(); + let err = require_role_conn(&conn, &id(outsider.clone()), project, min).unwrap_err(); assert!(matches!(err, LificError::Forbidden(_)), "denied at {min} got {err:?}"); } } @@ -518,9 +525,9 @@ mod tests { let viewer = seed_user(&conn, "viewer", false); upsert_member(&conn, project, viewer.id, Role::Viewer).unwrap(); - assert!(require_role_conn(&conn, &Some(viewer.clone()), project, Role::Viewer).is_ok()); - assert!(require_role_conn(&conn, &Some(viewer.clone()), project, Role::Maintainer).is_err()); - assert!(require_role_conn(&conn, &Some(viewer.clone()), project, Role::Lead).is_err()); + assert!(require_role_conn(&conn, &id(viewer.clone()), project, Role::Viewer).is_ok()); + assert!(require_role_conn(&conn, &id(viewer.clone()), project, Role::Maintainer).is_err()); + assert!(require_role_conn(&conn, &id(viewer.clone()), project, Role::Lead).is_err()); } #[test] @@ -532,9 +539,9 @@ mod tests { let maintainer = seed_user(&conn, "maintainer", false); upsert_member(&conn, project, maintainer.id, Role::Maintainer).unwrap(); - assert!(require_role_conn(&conn, &Some(maintainer.clone()), project, Role::Viewer).is_ok()); - assert!(require_role_conn(&conn, &Some(maintainer.clone()), project, Role::Maintainer).is_ok()); - assert!(require_role_conn(&conn, &Some(maintainer.clone()), project, Role::Lead).is_err()); + assert!(require_role_conn(&conn, &id(maintainer.clone()), project, Role::Viewer).is_ok()); + assert!(require_role_conn(&conn, &id(maintainer.clone()), project, Role::Maintainer).is_ok()); + assert!(require_role_conn(&conn, &id(maintainer.clone()), project, Role::Lead).is_err()); } #[test] @@ -547,7 +554,7 @@ mod tests { upsert_member(&conn, project, lead.id, Role::Lead).unwrap(); for min in [Role::Viewer, Role::Maintainer, Role::Lead] { - assert!(require_role_conn(&conn, &Some(lead.clone()), project, min).is_ok()); + assert!(require_role_conn(&conn, &id(lead.clone()), project, min).is_ok()); } } @@ -560,7 +567,7 @@ mod tests { let admin = seed_user(&conn, "admin", true); for min in [Role::Viewer, Role::Maintainer, Role::Lead] { - assert!(require_role_conn(&conn, &Some(admin.clone()), project, min).is_ok()); + assert!(require_role_conn(&conn, &id(admin.clone()), project, min).is_ok()); } } @@ -576,82 +583,68 @@ mod tests { } } - // ── Operator-key trust rule (LIF-261) ──────────────────────── + // ── Operator-key trust (LIF-261) — LIFIC-10 redesign ────────── // - // These call `require_role_conn_op` with the operator signal passed - // explicitly so both the operator and non-operator determination are - // exercised deterministically, without depending on an ambient task-local - // or MCP global. The end-to-end wiring (auth middleware → operator_scope / - // MCP global) is proven by the middleware tests in `auth.rs` / `mcp/mod.rs`. - + // The operator bypass is no longer a separate signal. Under the new + // design, an unbound API key resolves (via `resolve_caller`) to the first + // admin, so `identity.user.is_admin` admits it in the same `is_admin` + // short-circuit every admin hits. These tests prove that contract through + // the resolved identity the middleware would actually produce. + + // An unbound API key's resolved identity is the first admin → passes every + // level in enforced mode, exactly as the LIF-261 carrier-signal path did + // before. Replaces the deleted `require_role_conn_op(.., true)` test. #[test] - fn enforced_operator_unbound_key_passes_all_levels() { + fn enforced_resolved_first_admin_passes_all_levels_like_operator_key_did() { let pool = test_db(); let conn = pool.write().unwrap(); enable_enforcement(&conn); + let _admin = seed_user(&conn, "admin", true); // becomes first_admin let project = seed_project(&conn, "OPR"); // no membership rows at all - // A `None` effective user WITH the operator signal is admin-equivalent. + let identity = first_admin_identity(&conn).expect("first admin resolves"); for min in [Role::Viewer, Role::Maintainer, Role::Lead] { assert!( - require_role_conn_op(&conn, &None, project, min, true).is_ok(), - "operator-trusted unbound key must pass {min} in enforced mode" + require_role_conn(&conn, &Some(identity.clone()), project, min).is_ok(), + "resolved-first-admin (unbound key) must pass {min} in enforced mode" ); } } - // THE critical test: a legacy pre-binding OAuth token also resolves to - // `None`, but it is NOT an operator credential, so it must stay - // default-denied in enforced mode. Proves the operator bypass is gated on - // the credential-type signal, never on `effective` being `None`. + // A non-member resolved to a non-admin stays forbidden at every level — + // the bypass is `is_admin`, not merely "an identity exists." #[test] - fn enforced_legacy_unbound_oauth_none_stays_forbidden_all_levels() { + fn enforced_resolved_non_admin_non_member_stays_forbidden_all_levels() { let pool = test_db(); let conn = pool.write().unwrap(); enable_enforcement(&conn); - let project = seed_project(&conn, "OAU"); + seed_user(&conn, "someone", true); // first_admin, but irrelevant here + let project = seed_project(&conn, "ONA"); + let regular = seed_user(&conn, "regular", false); for min in [Role::Viewer, Role::Maintainer, Role::Lead] { assert!( - require_role_conn_op(&conn, &None, project, min, false).is_err(), - "a legacy unbound OAuth token (None, non-operator) must stay Forbidden at {min}" + require_role_conn(&conn, &id(regular.clone()), project, min).is_err(), + "a resolved non-admin non-member must stay Forbidden at {min}" ); } } - // The operator signal never *demotes* a real user: it's a bypass, so with - // it set a real non-member is still allowed (admin-equivalent). And with it - // unset the same non-member is denied — i.e. the signal is what flips it. + // The bypass is enforced-mode-relevant only because legacy mode already + // admits None/outsiders at Viewer/Maintainer; but the Lead gate stays + // admin/lead-only. A resolved first-admin still passes Lead (the point of + // the unbound-key escape hatch); a None identity (pre-LIFIC-9 bootstrap) + // does not. #[test] - fn enforced_operator_signal_is_the_only_difference_for_a_nonmember() { + fn legacy_mode_lead_gate_admits_resolved_admin_denies_none() { let pool = test_db(); let conn = pool.write().unwrap(); - enable_enforcement(&conn); - let project = seed_project(&conn, "SIG"); - - assert!( - require_role_conn_op(&conn, &None, project, Role::Viewer, false).is_err(), - "no operator signal: denied" - ); - assert!( - require_role_conn_op(&conn, &None, project, Role::Viewer, true).is_ok(), - "operator signal present: allowed" - ); - } - - // The operator bypass is enforced-mode only; in legacy mode the flag is a - // no-op because unbound `None` already passes Viewer/Maintainer there and - // the Lead gate stays admin/lead-only regardless. - #[test] - fn legacy_mode_operator_flag_does_not_change_lead_gate() { - let pool = test_db(); - let conn = pool.write().unwrap(); - // flag OFF (default) + seed_user(&conn, "admin", true); let project = seed_project(&conn, "LGO"); // unowned - // Even with the operator flag set, legacy Lead gate on an unowned - // project denies a None user (matches pre-existing require_admin/lead). - assert!(require_role_conn_op(&conn, &None, project, Role::Lead, true).is_err()); + let identity = first_admin_identity(&conn).expect("first admin resolves"); + assert!(require_role_conn(&conn, &Some(identity.clone()), project, Role::Lead).is_ok()); + assert!(require_role_conn(&conn, &None, project, Role::Lead).is_err()); } // ── Legacy mode (flag OFF, default) ───────────────────────── @@ -666,7 +659,7 @@ mod tests { for min in [Role::Viewer, Role::Maintainer] { assert!(require_role_conn(&conn, &None, project, min).is_ok(), "None user at {min}"); assert!( - require_role_conn(&conn, &Some(outsider.clone()), project, min).is_ok(), + require_role_conn(&conn, &id(outsider.clone()), project, min).is_ok(), "non-member at {min}" ); } @@ -693,21 +686,21 @@ mod tests { .unwrap() .id; - assert!(require_role_conn(&conn, &Some(lead.clone()), project, Role::Lead).is_ok()); - assert!(require_role_conn(&conn, &Some(admin.clone()), project, Role::Lead).is_ok()); - assert!(require_role_conn(&conn, &Some(regular.clone()), project, Role::Lead).is_err()); + assert!(require_role_conn(&conn, &id(lead.clone()), project, Role::Lead).is_ok()); + assert!(require_role_conn(&conn, &id(admin.clone()), project, Role::Lead).is_ok()); + assert!(require_role_conn(&conn, &id(regular.clone()), project, Role::Lead).is_err()); assert!(require_role_conn(&conn, &None, project, Role::Lead).is_err()); // Additive: a co-lead granted purely via project_members (no // lead_user_id change) also passes at Lead in legacy mode. let co_lead = seed_user(&conn, "co_lead", false); upsert_member(&conn, project, co_lead.id, Role::Lead).unwrap(); - assert!(require_role_conn(&conn, &Some(co_lead), project, Role::Lead).is_ok()); + assert!(require_role_conn(&conn, &id(co_lead), project, Role::Lead).is_ok()); // A plain viewer/maintainer membership does NOT grant Lead in legacy mode. let viewer_only = seed_user(&conn, "viewer_only", false); upsert_member(&conn, project, viewer_only.id, Role::Viewer).unwrap(); - assert!(require_role_conn(&conn, &Some(viewer_only), project, Role::Lead).is_err()); + assert!(require_role_conn(&conn, &id(viewer_only), project, Role::Lead).is_err()); } #[test] @@ -717,7 +710,7 @@ mod tests { let regular = seed_user(&conn, "regular", false); let project = seed_project(&conn, "UNO"); // lead_user_id = None - let err = require_role_conn(&conn, &Some(regular), project, Role::Lead).unwrap_err(); + let err = require_role_conn(&conn, &id(regular), project, Role::Lead).unwrap_err(); match err { LificError::Forbidden(msg) => assert!( msg.contains("no lead"), @@ -734,7 +727,7 @@ mod tests { let admin = seed_user(&conn, "admin", true); let project = seed_project(&conn, "UN2"); - assert!(require_role_conn(&conn, &Some(admin), project, Role::Lead).is_ok()); + assert!(require_role_conn(&conn, &id(admin), project, Role::Lead).is_ok()); } // ── Bot → owner inheritance ────────────────────────────────── @@ -749,10 +742,10 @@ mod tests { upsert_member(&conn, project, owner.id, Role::Maintainer).unwrap(); let bot = seed_bot(&conn, "bot1", Some(owner.id)); - assert!(require_role_conn(&conn, &Some(bot.clone()), project, Role::Viewer).is_ok()); - assert!(require_role_conn(&conn, &Some(bot.clone()), project, Role::Maintainer).is_ok()); + assert!(require_role_conn(&conn, &id(bot.clone()), project, Role::Viewer).is_ok()); + assert!(require_role_conn(&conn, &id(bot.clone()), project, Role::Maintainer).is_ok()); assert!( - require_role_conn(&conn, &Some(bot), project, Role::Lead).is_err(), + require_role_conn(&conn, &id(bot), project, Role::Lead).is_err(), "bot must never exceed its owner's role" ); } @@ -767,7 +760,7 @@ mod tests { let bot = seed_bot(&conn, "bot2", Some(owner.id)); for min in [Role::Viewer, Role::Maintainer, Role::Lead] { - assert!(require_role_conn(&conn, &Some(bot.clone()), project, min).is_ok()); + assert!(require_role_conn(&conn, &id(bot.clone()), project, min).is_ok()); } } @@ -781,10 +774,10 @@ mod tests { // No membership row for the bot itself -> denied, same as any // non-member, proving it did NOT silently inherit anyone else's role. - assert!(require_role_conn(&conn, &Some(bot.clone()), project, Role::Viewer).is_err()); + assert!(require_role_conn(&conn, &id(bot.clone()), project, Role::Viewer).is_err()); upsert_member(&conn, project, bot.id, Role::Viewer).unwrap(); - assert!(require_role_conn(&conn, &Some(bot), project, Role::Viewer).is_ok()); + assert!(require_role_conn(&conn, &id(bot), project, Role::Viewer).is_ok()); } // ── visible_project_ids ────────────────────────────────────── @@ -797,7 +790,7 @@ mod tests { enable_enforcement(&conn); seed_user(&conn, "admin", true) }; - assert_eq!(visible_project_ids(&pool, &Some(admin)).unwrap(), None); + assert_eq!(visible_project_ids(&pool, &id(admin)).unwrap(), None); } #[test] @@ -807,7 +800,7 @@ mod tests { let conn = pool.write().unwrap(); seed_user(&conn, "someone", false) }; - assert_eq!(visible_project_ids(&pool, &Some(user)).unwrap(), None); + assert_eq!(visible_project_ids(&pool, &id(user)).unwrap(), None); assert_eq!(visible_project_ids(&pool, &None).unwrap(), None); } @@ -826,7 +819,7 @@ mod tests { (user, p1, p2, p3) }; - let visible = visible_project_ids(&pool, &Some(user)).unwrap().unwrap(); + let visible = visible_project_ids(&pool, &id(user)).unwrap().unwrap(); assert_eq!(visible, HashSet::from([p1, p2])); } @@ -840,61 +833,66 @@ mod tests { assert_eq!(visible_project_ids(&pool, &None).unwrap(), Some(HashSet::new())); } - // LIF-261: an operator-trusted unbound key sees everything (unrestricted), - // like an admin — proven here through the ambient `operator_scope` - // task-local, which is the exact carrier the REST middleware uses. + // LIF-261 / LIFIC-10/14: an operator-trusted unbound key resolves (via + // `resolve_caller`) to the first admin, so its resolved identity is + // `is_admin` and `visible_project_ids` returns `None` (unrestricted). The + // old ambient `operator_scope` carrier that produced this outcome is gone. #[tokio::test] - async fn visible_project_ids_operator_scope_returns_none() { + async fn visible_project_ids_resolved_first_admin_returns_none() { let pool = test_db(); - { + let identity = { let conn = pool.write().unwrap(); enable_enforcement(&conn); + seed_user(&conn, "admin", true); seed_project(&conn, "V1"); seed_project(&conn, "V2"); - } - // Without the operator scope, a None user is confined to the empty set. + first_admin_identity(&conn).expect("first admin resolves") + }; + // A None identity (pre-LIFIC-9 bootstrap) is confined to the empty set. assert_eq!( visible_project_ids(&pool, &None).unwrap(), Some(HashSet::new()) ); - // Inside operator_scope(true), the same None user is unrestricted. - let got = operator_scope(true, async { visible_project_ids(&pool, &None) }).await.unwrap(); - assert_eq!(got, None, "operator sees all projects (unrestricted)"); + // The resolved-first-admin identity an unbound key produces is unrestricted. + assert_eq!( + visible_project_ids(&pool, &Some(identity.clone())).unwrap(), + None, + "resolved-first-admin (unbound key) sees all projects" + ); } - // LIF-261: the ambient operator context flips require_role end-to-end via - // the same task-local the REST auth middleware scopes around a request. + // LIF-261 / LIFIC-10: the operator bypass now flows through the resolved + // identity rather than an ambient task-local. A resolved first-admin + // (unbound-key path) passes every level; a None identity is denied. #[tokio::test] - async fn require_role_reads_ambient_operator_scope() { + async fn require_role_admits_resolved_first_admin_denies_none() { let pool = test_db(); - let project = { + let (project, identity, regular) = { let conn = pool.write().unwrap(); enable_enforcement(&conn); - seed_project(&conn, "AMB") + seed_user(&conn, "admin", true); + let project = seed_project(&conn, "AMB"); + let identity = first_admin_identity(&conn).expect("first admin resolves"); + let regular = seed_user(&conn, "regular", false); + (project, identity, regular) }; - // No scope: None user denied at Viewer in enforced mode. + // None identity: denied at Viewer in enforced mode. assert!(require_role(&pool, &None, project, Role::Viewer).is_err()); - // Inside operator_scope(true): allowed at every level. - operator_scope(true, async { - for min in [Role::Viewer, Role::Maintainer, Role::Lead] { - assert!( - require_role(&pool, &None, project, min).is_ok(), - "ambient operator scope must pass {min}" - ); - } - }) - .await; - - // operator_scope(false) must NOT grant the bypass. - operator_scope(false, async { + // Resolved first-admin: allowed at every level (the unbound-key bypass). + for min in [Role::Viewer, Role::Maintainer, Role::Lead] { assert!( - require_role(&pool, &None, project, Role::Viewer).is_err(), - "operator_scope(false) is not a bypass" + require_role(&pool, &Some(identity.clone()), project, min).is_ok(), + "resolved-first-admin must pass {min}" ); - }) - .await; + } + + // A resolved non-admin non-member is still denied. + assert!( + require_role(&pool, &id(regular), project, Role::Viewer).is_err(), + "resolved non-admin is not a bypass" + ); } // ── require_structure_role (LIF-197) ──────────────────────── @@ -923,9 +921,9 @@ mod tests { upsert_member(&conn, project, maintainer.id, Role::Maintainer).unwrap(); drop(conn); - assert!(require_structure_role(&pool, &Some(lead), project).is_ok()); + assert!(require_structure_role(&pool, &id(lead), project).is_ok()); assert!( - require_structure_role(&pool, &Some(maintainer), project).is_err(), + require_structure_role(&pool, &id(maintainer), project).is_err(), "maintainer-only membership must not pass the legacy structure gate" ); } @@ -942,8 +940,8 @@ mod tests { upsert_member(&conn, project, viewer.id, Role::Viewer).unwrap(); drop(conn); - assert!(require_structure_role(&pool, &Some(maintainer), project).is_ok()); - assert!(require_structure_role(&pool, &Some(viewer), project).is_err()); + assert!(require_structure_role(&pool, &id(maintainer), project).is_ok()); + assert!(require_structure_role(&pool, &id(viewer), project).is_err()); } // ── require_project_delete_role (LIF-197) ─────────────────── @@ -968,9 +966,9 @@ mod tests { .id; drop(conn); - assert!(require_project_delete_role(&pool, &Some(admin), project).is_ok()); + assert!(require_project_delete_role(&pool, &id(admin), project).is_ok()); assert!( - require_project_delete_role(&pool, &Some(lead), project).is_err(), + require_project_delete_role(&pool, &id(lead), project).is_err(), "legacy mode's delete gate is admin-only — a lead must still be refused, matching pre-LIF-194 require_admin" ); } @@ -987,8 +985,8 @@ mod tests { upsert_member(&conn, project, maintainer.id, Role::Maintainer).unwrap(); drop(conn); - assert!(require_project_delete_role(&pool, &Some(lead), project).is_ok()); - assert!(require_project_delete_role(&pool, &Some(maintainer), project).is_err()); + assert!(require_project_delete_role(&pool, &id(lead), project).is_ok()); + assert!(require_project_delete_role(&pool, &id(maintainer), project).is_err()); } // ── require_workspace_admin (LIF-197) ─────────────────────── @@ -1000,7 +998,7 @@ mod tests { let regular = seed_user(&conn, "regular", false); drop(conn); - assert!(require_workspace_admin(&pool, &Some(regular)).is_ok()); + assert!(require_workspace_admin(&pool, &id(regular)).is_ok()); assert!(require_workspace_admin(&pool, &None).is_ok()); } @@ -1013,8 +1011,8 @@ mod tests { let regular = seed_user(&conn, "regular", false); drop(conn); - assert!(require_workspace_admin(&pool, &Some(admin)).is_ok()); - assert!(require_workspace_admin(&pool, &Some(regular)).is_err()); + assert!(require_workspace_admin(&pool, &id(admin)).is_ok()); + assert!(require_workspace_admin(&pool, &id(regular)).is_err()); assert!(require_workspace_admin(&pool, &None).is_err()); } @@ -1059,11 +1057,11 @@ mod tests { for min in [Role::Viewer, Role::Maintainer, Role::Lead] { assert!( - require_role_conn(&conn, &Some(lead.clone()), project, min).is_ok(), + require_role_conn(&conn, &id(lead.clone()), project, min).is_ok(), "creation-time-seeded lead must retain {min} access once enforcement is on" ); assert!( - require_role_conn(&conn, &Some(second.clone()), project, min).is_err(), + require_role_conn(&conn, &id(second.clone()), project, min).is_err(), "a second user with no membership row must have no implicit access at {min}, including read" ); } @@ -1080,13 +1078,13 @@ mod tests { // Flag off (default): legacy mode, Viewer is unconditionally allowed. assert!(!authz_enforced_conn(&conn).unwrap()); - assert!(require_role_conn(&conn, &Some(outsider.clone()), project, Role::Viewer).is_ok()); + assert!(require_role_conn(&conn, &id(outsider.clone()), project, Role::Viewer).is_ok()); // Flip it on mid-test, same connection/pool, no restart. enable_enforcement(&conn); assert!(authz_enforced_conn(&conn).unwrap()); assert!( - require_role_conn(&conn, &Some(outsider.clone()), project, Role::Viewer).is_err(), + require_role_conn(&conn, &id(outsider.clone()), project, Role::Viewer).is_err(), "outsider must now be denied — no membership row" ); @@ -1096,6 +1094,6 @@ mod tests { InstanceSettingsPatch { authz_enforced: Some(false), ..Default::default() }, ) .unwrap(); - assert!(require_role_conn(&conn, &Some(outsider), project, Role::Viewer).is_ok()); + assert!(require_role_conn(&conn, &id(outsider), project, Role::Viewer).is_ok()); } } diff --git a/src/cli/connect/clients.rs b/src/cli/connect/clients.rs index 98a6daec..e9562014 100644 --- a/src/cli/connect/clients.rs +++ b/src/cli/connect/clients.rs @@ -29,6 +29,11 @@ pub enum Transport { Stdio { /// Absolute path to the SQLite database the spawned server should open. db_path: String, + /// The agent credential carried by this stdio session (LIFIC-18). Written + /// into the client config's env field as `LIFIC_TOKEN` so the spawned + /// server resolves the caller as the bound agent. `None` for a plain + /// (operator) stdio config with no agent identity. + token: Option, }, /// Remote streamable-HTTP server reached over the network, written WITHOUT /// any `Authorization` header — the client's native MCP OAuth flow (DCR + @@ -65,6 +70,19 @@ impl ServerConfig { name: "lific".into(), transport: Transport::Stdio { db_path: db_path.into(), + token: None, + }, + } + } + + /// A stdio server carrying an agent credential (LIFIC-18). The token is + /// written into the client config's env field as `LIFIC_TOKEN`. + pub fn stdio_with_token(db_path: impl Into, token: impl Into) -> Self { + Self { + name: "lific".into(), + transport: Transport::Stdio { + db_path: db_path.into(), + token: Some(token.into()), }, } } @@ -198,6 +216,11 @@ pub struct ClientSpec { /// config, and the post-connect auth hint (LIF-259 `--oauth`). pub oauth: OauthSupport, pub format: Format, + /// The env-field name this client uses for a stdio command's environment + /// (LIFIC-18): `environment` for OpenCode, `env` for Claude Code and Codex, + /// per those tools' config schemas. `None` when the client's stdio entry + /// cannot carry an env map (the token is then simply not written). + pub stdio_env_key: Option<&'static str>, /// Compute the global-scope config path for this OS, or `None` if the /// client has no global config (rare). global_path: fn(&PathBase) -> Option, @@ -224,6 +247,15 @@ impl ClientSpec { // The mappers don't set `name` themselves; inject the canonical server // name here so there's one source of truth (always `"lific"`). entry.name = cfg.name.clone(); + // LIFIC-18: for a stdio transport carrying an agent token, write the + // token into the client's env field as LIFIC_TOKEN so the spawned + // server resolves the caller as the bound agent. Clients that can't + // carry an env map get the token silently dropped (i.e. write nothing). + if let (Transport::Stdio { token: Some(tok), .. }, Some(env_key)) = + (&cfg.transport, self.stdio_env_key) + { + entry.value[env_key] = serde_json::json!({ "LIFIC_TOKEN": tok }); + } entry } @@ -315,6 +347,7 @@ pub fn all_clients() -> Vec { hint: "opencode mcp auth lific", }, format: Format::Json, + stdio_env_key: Some("environment"), global_path: |b| Some(config_dir(b, &["opencode", "opencode.json"])), project_path: |b| Some(project_rel(b, &["opencode.json"])), detect_extra: no_extra, @@ -343,7 +376,7 @@ pub fn all_clients() -> Vec { }), notes: vec![], }, - Transport::Stdio { db_path } => CompiledEntry { + Transport::Stdio { db_path, .. } => CompiledEntry { name: String::new(), top_key: "mcp".into(), value: serde_json::json!({ @@ -363,6 +396,7 @@ pub fn all_clients() -> Vec { hint: "claude mcp login lific (or /mcp inside a session)", }, format: Format::Json, + stdio_env_key: Some("env"), global_path: |b| Some(home_dot(b, &[".claude.json"])), project_path: |b| Some(project_rel(b, &[".mcp.json"])), detect_extra: no_extra, @@ -386,7 +420,7 @@ pub fn all_clients() -> Vec { }), notes: vec![], }, - Transport::Stdio { db_path } => CompiledEntry { + Transport::Stdio { db_path, .. } => CompiledEntry { name: String::new(), top_key: "mcpServers".into(), value: serde_json::json!({ @@ -409,6 +443,7 @@ pub fn all_clients() -> Vec { config-file entry — add Lific there instead.", }, format: Format::Json, + stdio_env_key: None, global_path: |b| { Some(match b.os { Os::Mac => home_dot( @@ -462,7 +497,7 @@ pub fn all_clients() -> Vec { }), notes: vec![], }, - Transport::Stdio { db_path } => CompiledEntry { + Transport::Stdio { db_path, .. } => CompiledEntry { name: String::new(), top_key: "mcpServers".into(), value: serde_json::json!({ @@ -481,6 +516,7 @@ pub fn all_clients() -> Vec { hint: "Cursor will prompt to authorize in-app on first connect", }, format: Format::Json, + stdio_env_key: None, global_path: |b| Some(home_dot(b, &[".cursor", "mcp.json"])), project_path: |b| Some(project_rel(b, &[".cursor", "mcp.json"])), detect_extra: |b, scope| match scope { @@ -505,7 +541,7 @@ pub fn all_clients() -> Vec { }), notes: vec![], }, - Transport::Stdio { db_path } => CompiledEntry { + Transport::Stdio { db_path, .. } => CompiledEntry { name: String::new(), top_key: "mcpServers".into(), value: serde_json::json!({ @@ -524,6 +560,7 @@ pub fn all_clients() -> Vec { hint: "VS Code starts the browser OAuth flow on first connect", }, format: Format::Json, + stdio_env_key: None, global_path: |b| { Some(match b.os { Os::Mac => home_dot( @@ -560,7 +597,7 @@ pub fn all_clients() -> Vec { }), notes: vec![], }, - Transport::Stdio { db_path } => CompiledEntry { + Transport::Stdio { db_path, .. } => CompiledEntry { name: String::new(), top_key: "servers".into(), value: serde_json::json!({ @@ -580,6 +617,7 @@ pub fn all_clients() -> Vec { hint: "codex mcp login lific", }, format: Format::Toml, + stdio_env_key: Some("env"), global_path: |b| Some(home_dot(b, &[".codex", "config.toml"])), project_path: |b| Some(project_rel(b, &[".codex", "config.toml"])), detect_extra: |b, scope| match scope { @@ -611,7 +649,7 @@ pub fn all_clients() -> Vec { }), notes: vec![], }, - Transport::Stdio { db_path } => CompiledEntry { + Transport::Stdio { db_path, .. } => CompiledEntry { name: String::new(), top_key: "mcp_servers.lific".into(), value: serde_json::json!({ @@ -630,6 +668,7 @@ pub fn all_clients() -> Vec { hint: "Zed runs the OAuth flow automatically when no header is set", }, format: Format::Json, + stdio_env_key: None, global_path: |b| Some(config_dir(b, &["zed", "settings.json"])), project_path: |_| None, detect_extra: |b, scope| match scope { @@ -654,7 +693,7 @@ pub fn all_clients() -> Vec { }), notes: vec![], }, - Transport::Stdio { db_path } => CompiledEntry { + Transport::Stdio { db_path, .. } => CompiledEntry { name: String::new(), top_key: "context_servers".into(), value: serde_json::json!({ @@ -673,6 +712,7 @@ pub fn all_clients() -> Vec { hint: "run /mcp auth lific inside Gemini CLI", }, format: Format::Json, + stdio_env_key: None, global_path: |b| Some(home_dot(b, &[".gemini", "settings.json"])), project_path: |b| Some(project_rel(b, &[".gemini", "settings.json"])), detect_extra: |b, scope| match scope { @@ -698,7 +738,7 @@ pub fn all_clients() -> Vec { }), notes: vec![], }, - Transport::Stdio { db_path } => CompiledEntry { + Transport::Stdio { db_path, .. } => CompiledEntry { name: String::new(), top_key: "mcpServers".into(), value: serde_json::json!({ @@ -717,6 +757,7 @@ pub fn all_clients() -> Vec { hint: "Windsurf prompts to authorize in-app on first connect", }, format: Format::Json, + stdio_env_key: None, global_path: |b| Some(home_dot(b, &[".codeium", "windsurf", "mcp_config.json"])), project_path: |_| None, detect_extra: |b, scope| match scope { @@ -742,7 +783,7 @@ pub fn all_clients() -> Vec { }), notes: vec![], }, - Transport::Stdio { db_path } => CompiledEntry { + Transport::Stdio { db_path, .. } => CompiledEntry { name: String::new(), top_key: "mcpServers".into(), value: serde_json::json!({ @@ -764,6 +805,7 @@ pub fn all_clients() -> Vec { (drop --oauth) to connect it.", }, format: Format::Yaml, + stdio_env_key: None, global_path: |b| Some(config_dir(b, &["goose", "config.yaml"])), project_path: |_| None, detect_extra: |b, scope| match scope { @@ -802,7 +844,7 @@ pub fn all_clients() -> Vec { }), notes: vec![], }, - Transport::Stdio { db_path } => CompiledEntry { + Transport::Stdio { db_path, .. } => CompiledEntry { name: String::new(), top_key: "extensions".into(), value: serde_json::json!({ @@ -830,6 +872,7 @@ pub fn all_clients() -> Vec { (drop --oauth) to connect it.", }, format: Format::Json, + stdio_env_key: None, global_path: |b| Some(config_dir(b, &["crush", "crush.json"])), project_path: |b| Some(project_rel(b, &["crush.json"])), detect_extra: |b, scope| match scope { @@ -858,7 +901,7 @@ pub fn all_clients() -> Vec { }), notes: vec![], }, - Transport::Stdio { db_path } => CompiledEntry { + Transport::Stdio { db_path, .. } => CompiledEntry { name: String::new(), top_key: "mcp".into(), value: serde_json::json!({ @@ -904,6 +947,10 @@ mod tests { ServerConfig::stdio("/abs/lific.db") } + fn stdio_token_cfg() -> ServerConfig { + ServerConfig::stdio_with_token("/abs/lific.db", "lific_sk-live-AGENTTOKEN") + } + fn oauth_cfg() -> ServerConfig { ServerConfig::oauth_remote("http://127.0.0.1:3456/mcp") } @@ -1004,6 +1051,83 @@ mod tests { ); } + // ── LIFIC-18: stdio agent token → env field ──────────────────────────── + + #[test] + fn opencode_stdio_token_writes_into_environment_field() { + let e = find_client("opencode") + .unwrap() + .compile(&stdio_token_cfg()); + assert_eq!(e.value["type"], "local"); + // Command stays unchanged. + assert_eq!( + e.value["command"], + serde_json::json!(["lific", "--db", "/abs/lific.db", "mcp"]) + ); + // opencode names its env field `environment`. + assert_eq!(e.value["environment"]["LIFIC_TOKEN"], "lific_sk-live-AGENTTOKEN"); + } + + #[test] + fn claude_code_stdio_token_writes_into_env_field() { + let e = find_client("claude-code") + .unwrap() + .compile(&stdio_token_cfg()); + assert_eq!(e.value["type"], "stdio"); + assert_eq!(e.value["env"]["LIFIC_TOKEN"], "lific_sk-live-AGENTTOKEN"); + } + + #[test] + fn codex_stdio_token_writes_into_env_field() { + let e = find_client("codex").unwrap().compile(&stdio_token_cfg()); + assert_eq!(e.value["env"]["LIFIC_TOKEN"], "lific_sk-live-AGENTTOKEN"); + } + + #[test] + fn stdio_without_token_writes_no_env_entry() { + // A plain stdio config (operator, no agent) must not invent an env map. + for id in ["opencode", "claude-code", "codex"] { + let e = find_client(id).unwrap().compile(&stdio_cfg()); + assert!( + e.value.get("environment").is_none() && e.value.get("env").is_none(), + "{id} plain stdio must not write an env field" + ); + } + } + + #[test] + fn stdio_token_for_env_incapable_client_is_dropped() { + // Clients with no documented env field (well, cursor here as stand-in) + // must not write the token into an invented key. + let e = find_client("cursor").unwrap().compile(&stdio_token_cfg()); + assert!(e.value.get("env").is_none()); + assert!(e.value.get("environment").is_none()); + // The stdio command still stands. + assert_eq!(e.value["command"], "lific"); + } + + #[test] + fn connect_stdio_env_uses_per_client_env_key() { + // opencode=environment, claude-code & codex=env — the three named in + // the spec. Others are None. + let spec_env = |id: &str| find_client(id).unwrap().stdio_env_key; + assert_eq!(spec_env("opencode"), Some("environment")); + assert_eq!(spec_env("claude-code"), Some("env")); + assert_eq!(spec_env("codex"), Some("env")); + for id in [ + "claude-desktop", + "cursor", + "vscode", + "zed", + "gemini", + "windsurf", + "goose", + "crush", + ] { + assert_eq!(spec_env(id), None, "{id} has no documented stdio env field"); + } + } + #[test] fn claude_code_paths_and_http_type() { let base = linux_base(); diff --git a/src/cli/connect/mod.rs b/src/cli/connect/mod.rs index a94fbad0..5d879dbe 100644 --- a/src/cli/connect/mod.rs +++ b/src/cli/connect/mod.rs @@ -60,6 +60,7 @@ use clients::{ClientSpec, OauthSupport, Os, PathBase, Scope, ServerConfig}; /// Parsed, validated arguments for a `connect` run. Built from the CLI enum in /// `cli/mod.rs` so the heavy lifting here is testable without clap. +#[derive(Debug, Clone)] pub struct ConnectArgs { pub clients: Vec, pub scope: Scope, @@ -75,6 +76,19 @@ pub struct ConnectArgs { pub skip_agents: bool, } +/// The transport a `lific connect` run uses, resolved once from flags or the +/// interactive menu. Both paths funnel through [`resolve_transport_inner`] so +/// the interactive and flag-driven runs can never diverge (LIFIC-19). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportMode { + /// Local stdio: spawn `lific --db mcp` (agent token LIFIC-18). + Stdio, + /// Streamable-HTTP remote with a bearer API key. + Remote, + /// Header-less remote driving the client's native MCP OAuth flow. + Oauth, +} + /// The outcome for a single client write, for both human and JSON output. #[derive(Debug, Default)] pub struct ClientOutcome { @@ -121,6 +135,9 @@ pub struct ConnectResult { /// True when this run wrote header-less OAuth configs (`--oauth`). pub oauth: bool, pub url: String, + /// The resolved transport (LIFIC-19), so interactive and flag runs report + /// the same choice. + pub transport: TransportMode, } #[derive(Debug)] @@ -214,10 +231,17 @@ pub fn absolute_db_path(cfg: &Config) -> String { } /// Build the canonical [`ServerConfig`] for one client. `key` is that client's -/// own key (ignored for stdio and oauth transports). +/// own credential. For a remote transport it's the bearer API key; for stdio +/// (LIFIC-18) it's the agent token written into the client config's env field +/// as `LIFIC_TOKEN`. Ignored for oauth transports. fn build_server_config(args: &ConnectArgs, cfg: &Config, key: &str) -> ServerConfig { if args.stdio { - ServerConfig::stdio(absolute_db_path(cfg)) + // An empty key (no agent identity) yields a plain operator stdio config. + if key.is_empty() { + ServerConfig::stdio(absolute_db_path(cfg)) + } else { + ServerConfig::stdio_with_token(absolute_db_path(cfg), key) + } } else if args.oauth { let url = args.url.clone().unwrap_or_else(|| default_url(cfg)); ServerConfig::oauth_remote(url) @@ -334,6 +358,78 @@ fn interactive_picker(detected: &[DetectedClient], target: &str) -> Result Result, +) -> Result { + if stdio { + return Ok(TransportMode::Stdio); + } + if oauth { + return Ok(TransportMode::Oauth); + } + if stdin_tty { + picker() + } else { + // No transport flag and not interactive → remote, the standing default. + Ok(TransportMode::Remote) + } +} + +/// The interactive transport menu: stdio preselected, with remote and OAuth +/// available. Does not prompt for a URL — the target URL is a server-config +/// fact derived upstream (LIFIC-19 AC: never a connect-time prompt). +fn interactive_transport_picker() -> Result { + let mut prompt = cliclack::Select::new("How should clients connect to this Lific instance?"); + prompt = prompt + .item( + TransportMode::Stdio, + "Local stdio", + "spawn lific --db mcp; the agent carries its own token", + ) + .item( + TransportMode::Remote, + "Remote (API key)", + "reach the running server over HTTP with a bearer key", + ) + .item( + TransportMode::Oauth, + "OAuth", + "header-less config; the client authenticates via its native MCP OAuth flow", + ) + .initial_value(TransportMode::Stdio); + prompt.interact().map_err(|e| { + if e.kind() == std::io::ErrorKind::Interrupted { + "cancelled".to_string() + } else { + format!("transport selection failed: {e}") + } + }) +} + +/// The CLI-shown label for a resolved transport (used in the run announcement +/// and JSON output) so both paths name the same thing. +impl TransportMode { + pub fn as_str(self) -> &'static str { + match self { + TransportMode::Stdio => "stdio", + TransportMode::Remote => "remote", + TransportMode::Oauth => "oauth", + } + } +} + // ── Key minting ────────────────────────────────────────────── /// How this run mints per-tool keys, resolved once up front (owner selection is @@ -402,19 +498,9 @@ fn mint_for_tool( let bot_username = format!("{}-{}", spec.id, owner_username); let bot_id = { let conn = pool.write().map_err(|e| e.to_string())?; - match crate::db::queries::users::find_bot_by_username(&conn, &bot_username) + crate::db::queries::users::ensure_bot(&conn, *owner_id, spec.id, spec.display) .map_err(|e| e.to_string())? - { - Some(existing) => existing.id, - None => crate::db::queries::users::create_bot_user( - &conn, - *owner_id, - &bot_username, - spec.display, - ) - .map_err(|e| e.to_string())? - .id, - } + .id }; let key = mint_or_rotate(pool, manager, &bot_username)?; { @@ -527,18 +613,59 @@ pub fn run( } let stdin_tty = std::io::stdin().is_terminal(); - let target = target_url(args, cfg); + + // LIFIC-19: resolve the transport — flags win (non-interactive path, the + // scripted equivalent); an interactive TTY with neither flag gets a visible + // menu with stdio preselected. Resolving into the same `ConnectArgs` the + // flag path would have produced guarantees the two can never diverge. + let transport = resolve_transport_inner(args.stdio, args.oauth, stdin_tty, || { + interactive_transport_picker() + })?; + let mut args = args.clone(); + match transport { + TransportMode::Stdio => { + args.stdio = true; + args.oauth = false; + } + TransportMode::Remote => { + args.stdio = false; + args.oauth = false; + } + TransportMode::Oauth => { + args.stdio = false; + args.oauth = true; + } + } + + let target = target_url(&args, cfg); let selected = resolve_clients_inner(&args.clients, stdin_tty, base, args.scope, |d| { interactive_picker(d, &target) })?; - // Resolve how per-tool keys are minted (once — owner selection is run-wide). - // Not needed for stdio (no key) or --oauth (mints nothing), and skipped in - // dry-run so a preview never touches the DB. - let needs_minting = !args.stdio && !args.oauth && !args.dry_run; + // Resolve how per-tool credentials are minted (once — owner selection is + // run-wide). Both remote and stdio (LIFIC-18) mint a bot + key per tool; + // for stdio the key becomes the `LIFIC_TOKEN` written into the client's + // env field. `--oauth` mints nothing, and dry-run is skipped so a preview + // never touches the DB. + let needs_minting = !args.oauth && !args.dry_run; let key_source = if needs_minting { - Some(resolve_key_source(args, pool)?) - } else if args.dry_run && !args.stdio && !args.oauth { + // For stdio (LIFIC-18), a bot+key is optional: if the owner can't be + // resolved unambiguously (no --user on a multi-user box), degrade to a + // plain operator stdio config rather than aborting the run — a stdio + // session with no token already runs as the operator. Remote still + // hard-fails: a remote config without a key is genuinely misconfigured. + match resolve_key_source(&args, pool) { + Ok(src) => Some(src), + Err(e) if args.stdio => { + eprintln!( + "warning: skipping agent identity for stdio config ({e}); \ + it will run as the operator until you reconnect with --user ." + ); + None + } + Err(e) => return Err(e), + } + } else if args.dry_run && !args.oauth { // Dry-run still reports an origin so output matches a real run's shape. Some(KeySource::Provided( "lific_sk-live-DRYRUN000000000000000000000000".to_string(), @@ -559,7 +686,7 @@ pub fn run( let outcomes = write_all_clients( &selected, - args, + &args, cfg, pool, base, @@ -568,7 +695,7 @@ pub fn run( )?; // AGENTS.md (LIF-251). - let agents_md = maybe_write_agents_md(args, base, stdin_tty)?; + let agents_md = maybe_write_agents_md(&args, base, stdin_tty)?; // A representative URL/db-path for the run-level summary. let url = if args.stdio { @@ -585,6 +712,7 @@ pub fn run( stdio: args.stdio, oauth: args.oauth, url, + transport, }) } @@ -825,6 +953,7 @@ fn print_json(result: &ConnectResult) { "dry_run": result.dry_run, "stdio": result.stdio, "oauth": result.oauth, + "transport": result.transport.as_str(), "url": result.url, "agents_md": result.agents_md.as_ref().map(|a| serde_json::json!({ "path": a.path.display().to_string(), @@ -840,6 +969,9 @@ fn print_human(result: &ConnectResult) { if result.dry_run { ui::info("Dry run — no files were written."); } + // LIFIC-19: surface the resolved transport so an interactive pick is never + // silent — the same choice a matching flag would have produced. + ui::step(format!("Transport: {}", ui::dim(result.transport.as_str()))); for o in &result.outcomes { match (&o.action, &o.error) { (Some(action), _) => { @@ -1080,6 +1212,78 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + // ── transport resolution (LIFIC-19) ────────────────────── + + fn no_transport_picker() -> Result { + panic!("picker must not be called when a transport flag is given or stdin is not a TTY") + } + + #[test] + fn transport_stdio_flag_wins_without_picker() { + assert_eq!( + resolve_transport_inner(true, false, true, no_transport_picker).unwrap(), + TransportMode::Stdio + ); + assert_eq!( + resolve_transport_inner(true, false, false, no_transport_picker).unwrap(), + TransportMode::Stdio + ); + } + + #[test] + fn transport_oauth_flag_wins_without_picker() { + assert_eq!( + resolve_transport_inner(false, true, true, no_transport_picker).unwrap(), + TransportMode::Oauth + ); + } + + #[test] + fn transport_non_tty_no_flag_defaults_to_remote() { + // The historical non-interactive default. No picker, no menu. + assert_eq!( + resolve_transport_inner(false, false, false, no_transport_picker).unwrap(), + TransportMode::Remote + ); + } + + #[test] + fn transport_tty_no_flag_calls_picker() { + let got = + resolve_transport_inner(false, false, true, || Ok(TransportMode::Stdio)).unwrap(); + assert_eq!(got, TransportMode::Stdio); + let got = + resolve_transport_inner(false, false, true, || Ok(TransportMode::Remote)).unwrap(); + assert_eq!(got, TransportMode::Remote); + let got = + resolve_transport_inner(false, false, true, || Ok(TransportMode::Oauth)).unwrap(); + assert_eq!(got, TransportMode::Oauth); + } + + #[test] + fn transport_labels_are_stable() { + assert_eq!(TransportMode::Stdio.as_str(), "stdio"); + assert_eq!(TransportMode::Remote.as_str(), "remote"); + assert_eq!(TransportMode::Oauth.as_str(), "oauth"); + } + + #[test] + fn run_remote_flag_and_nontty_default_agree() { + // LIFIC-19 AC: the interactive menu and the flag/non-interactive path + // must produce the same config for the same choice. Proving it at the + // seam: a non-TTY run (remote default) and an explicit --stdio run + // resolve to distinct transports, and the resolver decides them + // deterministically without a picker. + assert_eq!( + resolve_transport_inner(false, false, false, no_transport_picker).unwrap(), + TransportMode::Remote + ); + assert_eq!( + resolve_transport_inner(false, false, true, || Ok(TransportMode::Remote)).unwrap(), + TransportMode::Remote + ); + } + // ── detection ──────────────────────────────────────────── #[test] @@ -1200,6 +1404,283 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + // ── LIFIC-18: stdio agent token carrier ─────────────────────────────── + + #[test] + fn run_stdio_with_owner_mints_agent_key_written_into_env() { + let dir = tmp(); + let b = base(&dir); + let pool = db::open_memory().unwrap(); + let owner_id = seed_user(&pool, "solo", true); + let mut cfg = Config::default(); + cfg.database.path = dir.join("mydb.db"); + let mut a = args(&["opencode"], Scope::Project); + a.stdio = true; + a.key = None; + + let result = run(&a, &cfg, &pool, &b).unwrap(); + + // The outcome carries no bearer key (stdio surfaces none), but the + // config MUST carry the minted agent token in the env field. + assert!(result.outcomes.iter().all(|o| o.key.is_none())); + + let written = + std::fs::read_to_string(b.project.join("opencode.json")).unwrap(); + let v: serde_json::Value = serde_json::from_str(&written).unwrap(); + assert_eq!(v["mcp"]["lific"]["type"], "local"); + let token = v["mcp"]["lific"]["environment"]["LIFIC_TOKEN"] + .as_str() + .expect("stdio config must write LIFIC_TOKEN into environment"); + assert!(token.starts_with("lific_sk-live-"), "got {token}"); + + // The bot was minted as the per-tool agent owned by the operator. + let conn = pool.read().unwrap(); + let (is_bot, owner): (bool, Option) = conn + .query_row( + "SELECT is_bot, owner_id FROM users WHERE username = 'opencode-solo'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert!(is_bot, "opencode-solo must be a bot"); + assert_eq!(owner, Some(owner_id), "bot must be owned by the operator"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn run_stdio_openconfig_merges_with_existing_entries() { + let dir = tmp(); + let b = base(&dir); + let pool = db::open_memory().unwrap(); + seed_user(&pool, "solo", true); + let mut cfg = Config::default(); + cfg.database.path = dir.join("mydb.db"); + std::fs::create_dir_all(b.project.clone()).unwrap(); + std::fs::write( + b.project.join("opencode.json"), + r#"{ "mcp": { "other": { "type": "remote", "url": "http://other" } } }"#, + ) + .unwrap(); + let mut a = args(&["opencode"], Scope::Project); + a.stdio = true; + a.key = None; + + let result = run(&a, &cfg, &pool, &b).unwrap(); + assert_eq!(result.outcomes[0].action.as_deref(), Some("updated")); + + let written = + std::fs::read_to_string(b.project.join("opencode.json")).unwrap(); + let v: serde_json::Value = serde_json::from_str(&written).unwrap(); + // Unrelated entries survive. + assert_eq!(v["mcp"]["other"]["url"], "http://other"); + // Lific entry has the stdio command + token env. + assert_eq!(v["mcp"]["lific"]["type"], "local"); + assert!( + v["mcp"]["lific"]["environment"]["LIFIC_TOKEN"] + .as_str() + .is_some() + ); + std::fs::remove_dir_all(&dir).ok(); + } + + // ── connect idempotency / self-healing the stdio token ──────────────── + // + // If a stdio config already lists `lific` but is MISSING the token (e.g. it + // was written by a pre-token connect, or the env field was damaged), a + // re-run of `connect --stdio` must mint a token and repair the entry in + // place — reusing the same agent bot, keeping one active key, and not + // corrupting other entries. + + #[test] + fn run_stdio_reconnects_and_heals_a_tokenless_lific_entry() { + let dir = tmp(); + let b = base(&dir); + let pool = db::open_memory().unwrap(); + seed_user(&pool, "solo", true); + let mut cfg = Config::default(); + cfg.database.path = dir.join("mydb.db"); + std::fs::create_dir_all(b.project.clone()).unwrap(); + // A pre-existing stdio entry with NO environment/token, plus a sibling. + std::fs::write( + b.project.join("opencode.json"), + r#"{ "mcp": { "lific": { "type": "local", "command": ["lific", "--db", "/abs/lific.db", "mcp"] }, "other": { "type": "remote" } } }"#, + ) + .unwrap(); + let mut a = args(&["opencode"], Scope::Project); + a.stdio = true; + a.key = None; + + let result = run(&a, &cfg, &pool, &b).unwrap(); + assert_eq!(result.outcomes[0].action.as_deref(), Some("updated")); + + let v: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(b.project.join("opencode.json")).unwrap()) + .unwrap(); + // Lific entry healed: command kept, token env added. + assert_eq!(v["mcp"]["lific"]["type"], "local"); + assert_eq!( + v["mcp"]["lific"]["command"], + serde_json::json!(["lific", "--db", dir.join("mydb.db").display().to_string(), "mcp"]) + ); + let token = v["mcp"]["lific"]["environment"]["LIFIC_TOKEN"] + .as_str() + .expect("reconnect must write LIFIC_TOKEN"); + assert!(token.starts_with("lific_sk-live-")); + + // Sibling preserved. + assert_eq!(v["mcp"]["other"]["type"], "remote"); + + // The same agent bot is reused, not duplicated. + let conn = pool.read().unwrap(); + let bots: i64 = conn + .query_row( + "SELECT COUNT(*) FROM users WHERE username = 'opencode-solo'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(bots, 1, "reconnect must not duplicate the agent bot"); + assert_eq!(active_key_count(&pool, "opencode-solo"), 1); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn run_stdio_rerun_keeps_the_token_and_agent_stable() { + let dir = tmp(); + let b = base(&dir); + let pool = db::open_memory().unwrap(); + seed_user(&pool, "solo", true); + let mut cfg = Config::default(); + cfg.database.path = dir.join("mydb.db"); + let mut a = args(&["opencode"], Scope::Project); + a.stdio = true; + a.key = None; + + let _ = run(&a, &cfg, &pool, &b).unwrap(); + // Fresh connect run #2 must already be up-to-date and idempotent w.r.t. + // the DB state: same single bot, single active key, a valid token write. + let _ = run(&a, &cfg, &pool, &b).unwrap(); + let conn = pool.read().unwrap(); + let bots: i64 = conn + .query_row("SELECT COUNT(*) FROM users WHERE username = 'opencode-solo'", [], |r| r.get(0)) + .unwrap(); + let keys: i64 = conn + .query_row( + "SELECT COUNT(*) FROM api_keys WHERE name = 'opencode-solo' AND revoked = 0", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(bots, 1, "no agent churn across reruns"); + assert_eq!(keys, 1, "exactly one active key across reruns"); + + // The config remains well-formed and carries a live token after run #2 + // (a re-connect may rotate the key — that is a valid fresh plaintext). + let second_v: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(b.project.join("opencode.json")).unwrap()) + .unwrap(); + let second_token = second_v["mcp"]["lific"]["environment"]["LIFIC_TOKEN"] + .as_str() + .expect("token must be present after rerun"); + assert!(second_token.starts_with("lific_sk-live-")); + assert_eq!(second_v["mcp"]["lific"]["type"], "local"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn run_stdio_codex_writes_env_table_in_toml() { + let dir = tmp(); + let b = base(&dir); + let pool = db::open_memory().unwrap(); + seed_user(&pool, "solo", true); + let mut cfg = Config::default(); + cfg.database.path = dir.join("mydb.db"); + let mut a = args(&["codex"], Scope::Project); + a.stdio = true; + a.key = None; + + let result = run(&a, &cfg, &pool, &b).unwrap(); + let outcome = &result.outcomes[0]; + assert_eq!( + outcome.action.as_deref(), + Some("created"), + "codex stdio error: {:?}", + outcome.error + ); + + let written = + std::fs::read_to_string(b.project.join(".codex/config.toml")).unwrap(); + // The stdio command and the env table with LIFIC_TOKEN both land. + assert!( + written.contains("command = \"lific\""), + "codex stdio must keep the lific command:\n{written}" + ); + assert!( + written.contains("args = [\"--db\""), + "codex stdio must keep the db args:\n{written}" + ); + assert!( + written.contains("LIFIC_TOKEN"), + "codex stdio must write LIFIC_TOKEN into env:\n{written}" + ); + assert!( + written.contains("lific_sk-live-"), + "codex must carry a real minted key, not a placeholder:\n{written}" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn run_stdio_codex_env_merges_into_existing_config() { + // LIFIC-18 spec: "The config write merges into an existing tool config + // without destroying other entries." opencode covered it; this pins the + // codex TOML merge path, which touches a different writer branch. + let dir = tmp(); + let b = base(&dir); + let pool = db::open_memory().unwrap(); + seed_user(&pool, "solo", true); + let mut cfg = Config::default(); + cfg.database.path = dir.join("mydb.db"); + std::fs::create_dir_all(b.project.join(".codex")).unwrap(); + std::fs::write( + b.project.join(".codex/config.toml"), + "model = \"gpt-5\"\n\n[mcp_servers.other]\nurl = \"http://other\"\n", + ) + .unwrap(); + let mut a = args(&["codex"], Scope::Project); + a.stdio = true; + a.key = None; + + let result = run(&a, &cfg, &pool, &b).unwrap(); + assert_eq!(result.outcomes[0].action.as_deref(), Some("updated")); + + let written = + std::fs::read_to_string(b.project.join(".codex/config.toml")).unwrap(); + // Unrelated config survives. + assert!( + written.contains("model = \"gpt-5\""), + "user's model setting must survive:\n{written}" + ); + assert!( + written.contains("[mcp_servers.other]"), + "unrelated server table must survive:\n{written}" + ); + assert!( + written.contains("url = \"http://other\""), + "unrelated server's url must survive:\n{written}" + ); + // Our entry: stdio command + token env. + assert!( + written.contains("command = \"lific\""), + "codex lific command must be present:\n{written}" + ); + assert!( + written.contains("LIFIC_TOKEN"), + "codex must write LIFIC_TOKEN into env on merge:\n{written}" + ); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn run_dry_run_writes_nothing_but_returns_contents() { let dir = tmp(); @@ -1394,6 +1875,102 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + // ── reconnect healing, all transports (idempotency) ──────────────────── + // + // The same self-healing expectation as the stdio case, applied to remote + // (API-key) and OAuth: re-running connect over an existing lific entry + // repairs it in place, mints/asserts the right credential state, reuses the + // agent bot, and never duplicates entries or keys. + + #[test] + fn run_remote_reconnects_and_heals_a_stale_lific_entry() { + let dir = tmp(); + let b = base(&dir); + let pool = db::open_memory().unwrap(); + seed_user(&pool, "solo", true); + let cfg = Config::default(); + std::fs::create_dir_all(b.home.join(".config/opencode")).unwrap(); + // A pre-existing **remote** (API-key) entry pointing at a stale URL with + // a bogus key that no longer exists in the DB. + std::fs::write( + b.home.join(".config/opencode/opencode.json"), + r#"{ "mcp": { "lific": { "type": "remote", "url": "http://stale/mcp", "headers": { "Authorization": "Bearer lific_sk-live-STALE" } } } }"#, + ) + .unwrap(); + let mut a = args(&["opencode"], Scope::Global); + a.key = None; + a.url = Some("http://127.0.0.1:3456/mcp".into()); + + let result = run(&a, &cfg, &pool, &b).unwrap(); + assert_eq!(result.outcomes[0].action.as_deref(), Some("updated")); + + let v: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(b.home.join(".config/opencode/opencode.json")).unwrap(), + ) + .unwrap(); + // Healed: url corrected, a real minted key present. + assert_eq!(v["mcp"]["lific"]["url"], "http://127.0.0.1:3456/mcp"); + let auth = v["mcp"]["lific"]["headers"]["Authorization"] + .as_str() + .expect("remote reconnect must write an Authorization header"); + assert!(auth.starts_with("Bearer lific_sk-live-"), "got {auth}"); + + // The stale key is gone; one live bot key remains; one agent bot. + assert_eq!(active_key_count(&pool, "opencode-solo"), 1); + let conn = pool.read().unwrap(); + let bots: i64 = conn + .query_row( + "SELECT COUNT(*) FROM users WHERE username = 'opencode-solo'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(bots, 1, "remote reconnect must not duplicate the bot"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn run_oauth_reconnects_and_heals_a_stale_lific_entry() { + let dir = tmp(); + let b = base(&dir); + let pool = db::open_memory().unwrap(); + seed_user(&pool, "solo", true); + let cfg = Config::default(); + std::fs::create_dir_all(b.home.join(".config/opencode")).unwrap(); + // A pre-existing entry with a wrong URL and a stale bearer header. + std::fs::write( + b.home.join(".config/opencode/opencode.json"), + r#"{ "mcp": { "lific": { "type": "remote", "url": "http://old/", "headers": { "Authorization": "Bearer gone" } } } }"#, + ) + .unwrap(); + let mut a = args(&["opencode"], Scope::Global); + a.key = None; + a.oauth = true; + a.url = Some("http://127.0.0.1:3456/mcp".into()); + + let result = run(&a, &cfg, &pool, &b).unwrap(); + assert_eq!(result.outcomes[0].action.as_deref(), Some("updated")); + + // Healed: correct URL and NO headers (OAuth headerless). + let v: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(b.home.join(".config/opencode/opencode.json")).unwrap(), + ) + .unwrap(); + assert_eq!(v["mcp"]["lific"]["url"], "http://127.0.0.1:3456/mcp"); + assert!( + v["mcp"]["lific"].get("headers").is_none(), + "oauth reconnect must drop the stale Authorization header" + ); + + // OAuth mints nothing, so the DB stays untouched. + let conn = pool.read().unwrap(); + let keys: i64 = conn + .query_row("SELECT COUNT(*) FROM api_keys", [], |r| r.get(0)) + .unwrap(); + assert_eq!(keys, 0, "oauth reconnects mint no keys"); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn explicit_user_owns_the_bots() { let pool = db::open_memory().unwrap(); @@ -1433,6 +2010,46 @@ mod tests { assert!(err.contains("--user"), "must guide toward --user: {err}"); } + #[test] + fn run_stdio_with_ambiguity_degrades_to_plain_config_not_error() { + // LIFIC-19 review fix: a non-interactive `--stdio` on a multi-user box + // (no --user) must NOT hard-fail just because the agent owner can't be + // resolved. A stdio config with no token runs as the operator, which is + // the documented LIFIC-18 fallback — so the run succeeds and writes a + // plain (token-less) stdio config. + let dir = tmp(); + let b = base(&dir); + let pool = db::open_memory().unwrap(); + seed_user(&pool, "a", false); + seed_user(&pool, "b", false); // two humans, no single owner, no --user + let mut cfg = Config::default(); + cfg.database.path = dir.join("mydb.db"); + let mut a = args(&["opencode"], Scope::Project); + a.stdio = true; + a.key = None; + + let result = run(&a, &cfg, &pool, &b).unwrap(); + // The run succeeds and the config is written. + let oc = result.outcomes.iter().find(|o| o.id == "opencode").unwrap(); + assert_eq!(oc.action.as_deref(), Some("created")); + + // Plain stdio config: command present, but NO token (no env field, + // because no owner resolved → no LIFIC_TOKEN to bind). + let written = + std::fs::read_to_string(b.project.join("opencode.json")).unwrap(); + let v: serde_json::Value = serde_json::from_str(&written).unwrap(); + assert_eq!(v["mcp"]["lific"]["type"], "local"); + assert_eq!( + v["mcp"]["lific"]["command"], + serde_json::json!(["lific", "--db", dir.join("mydb.db").display().to_string(), "mcp"]) + ); + assert!( + v["mcp"]["lific"].get("environment").is_none(), + "ambiguous-owner stdio must write no token env field" + ); + std::fs::remove_dir_all(&dir).ok(); + } + // ── --oauth mode (LIF-259) ─────────────────────────────── #[test] diff --git a/src/cli/connect/writer.rs b/src/cli/connect/writer.rs index c3b65031..528adf9d 100644 --- a/src/cli/connect/writer.rs +++ b/src/cli/connect/writer.rs @@ -228,10 +228,11 @@ fn render_toml(existing: &str, entry: &CompiledEntry) -> Result Result { - use toml_edit::{Array, Item, Value, value}; + use toml_edit::{Array, Item, Value, value, InlineTable}; match v { serde_json::Value::String(s) => Ok(value(s.as_str())), serde_json::Value::Bool(b) => Ok(value(*b)), @@ -259,6 +260,19 @@ fn json_to_toml_value(v: &serde_json::Value) -> Result { + let mut inline = InlineTable::new(); + for (k, vv) in map { + let s = vv.as_str().ok_or_else(|| { + WriteError::new(format!("unsupported TOML object value for `{k}`")) + })?; + inline.insert(k, Value::from(s.to_string())); + } + Ok(Item::Value(Value::InlineTable(inline))) + } other => Err(WriteError::new(format!("unsupported TOML value: {other}"))), } } diff --git a/src/cli/http.rs b/src/cli/http.rs index 4e1b8b48..f50871d8 100644 --- a/src/cli/http.rs +++ b/src/cli/http.rs @@ -1251,7 +1251,25 @@ mod tests { username: "test-admin".into(), display_name: "Test Admin".into(), is_admin: true, - }))); + }))) + .layer(Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: admin_id, + username: "test-admin".into(), + display_name: "Test Admin".into(), + is_admin: true, + }, + transport: crate::actor::Transport::Web, + }))) + .layer(Extension(Some(crate::resolve_caller::ResolvedIdentity { + user: AuthUser { + id: admin_id, + username: "test-admin".into(), + display_name: "Test Admin".into(), + is_admin: true, + }, + transport: crate::actor::Transport::Web, + }))); let (project_id, _) = seed_project(&app).await; let project_page = parse_json( json_post( diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 1afa2ae9..37565763 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -198,6 +198,23 @@ pub enum Command { /// enforced at run time because --config is a global arg). #[arg(long)] here: bool, + + /// On a fresh install (no users yet), create the first human admin with + /// this name instead of prompting. The operator name cannot be prompted + /// for non-interactively. + #[arg(long)] + name: Option, + + /// On a fresh install (no human operator), choose the auth mode instead + /// of showing the interactive menu. Non-interactive path; must be one of + /// `login-free` or `passwords`. + #[arg(long)] + auth_mode: Option, + + /// The password for `--auth-mode passwords` (prompted interactively if + /// omitted). Ignored in login-free mode. + #[arg(long)] + password: Option, }, /// Manage the background service that `lific init` installs. @@ -1364,7 +1381,10 @@ mod tests { cli.command, Command::Init { no_service: false, - here: false + here: false, + name: _, + auth_mode: _, + password: _, } )); } @@ -1376,7 +1396,10 @@ mod tests { cli.command, Command::Init { no_service: true, - here: false + here: false, + name: _, + auth_mode: _, + password: _, } )); } diff --git a/src/cli/term.rs b/src/cli/term.rs index 6811c1a2..c71c2ad9 100644 --- a/src/cli/term.rs +++ b/src/cli/term.rs @@ -86,6 +86,50 @@ pub fn confirm_inner( Ok(answer == "y" || answer == "yes") } +/// Ask the user for a short (single-line) piece of text, e.g. an operator name. +/// +/// Same contract as [`confirm`]: refuses, rather than hangs, when stdin is not +/// a TTY, and names `bypass_flag` — the flag a non-interactive caller should +/// pass to supply the value without a prompt. Returns the trimmed input; errors +/// on empty input or no-TTY. +pub fn prompt_text(prompt: &str, bypass_flag: &str) -> Result { + prompt_text_inner( + prompt, + bypass_flag, + stdin_is_tty(), + &mut std::io::stdin().lock(), + &mut std::io::stdout(), + ) +} + +/// Pure/injected implementation of [`prompt_text`], factored out so the non-TTY +/// refusal branch and the reader plumbing are testable. +pub fn prompt_text_inner( + prompt: &str, + bypass_flag: &str, + stdin_tty: bool, + reader: &mut R, + writer: &mut W, +) -> Result { + if !stdin_tty { + return Err(format!( + "interactive input required; re-run with {bypass_flag} to supply it non-interactively" + )); + } + let _ = write!(writer, "{prompt} "); + let _ = writer.flush(); + + let mut line = String::new(); + reader + .read_line(&mut line) + .map_err(|e| format!("failed to read input: {e}"))?; + let value = line.trim().to_string(); + if value.is_empty() { + return Err("input cannot be empty".into()); + } + Ok(value) +} + #[cfg(test)] mod tests { use super::*; @@ -145,4 +189,35 @@ mod tests { let ok = confirm_inner("Proceed?", "--yes", true, &mut input, &mut out).unwrap(); assert!(!ok); } + + // LIFIC-9: the operator-name prompt shares confirm's non-TTY contract. + #[test] + fn prompt_text_refuses_without_tty_and_names_bypass_flag() { + let mut input: &[u8] = b""; + let err = prompt_text_inner("What's your name?", "--name", false, &mut input, &mut Vec::new()) + .expect_err("must refuse when stdin is not a TTY"); + assert!( + err.contains("--name"), + "error should name the bypass flag, got: {err}" + ); + assert!(err.contains("interactive"), "error should explain why: {err}"); + } + + #[test] + fn prompt_text_trims_response_on_tty() { + let mut input: &[u8] = b" Blake Alston \n"; + let value = + prompt_text_inner("What's your name?", "--name", true, &mut input, &mut Vec::new()) + .unwrap(); + assert_eq!(value, "Blake Alston"); + } + + #[test] + fn prompt_text_rejects_empty_input() { + let mut input: &[u8] = b"\n"; + let err = + prompt_text_inner("What's your name?", "--name", true, &mut input, &mut Vec::new()) + .expect_err("empty input must fail"); + assert!(err.contains("empty"), "error should explain why: {err}"); + } } diff --git a/src/config.rs b/src/config.rs index f32af787..a29d8957 100644 --- a/src/config.rs +++ b/src/config.rs @@ -75,26 +75,88 @@ impl AuthConfig { } } -/// Does this URL point at the local machine? Backs the LIF-294 startup guard: -/// an auth-optional instance must never have a non-localhost `public_url`. -/// Conservative — anything unparseable counts as NOT localhost. -pub fn is_localhost_url(url: &str) -> bool { - let rest = url.trim(); - let rest = rest.split("://").nth(1).unwrap_or(rest); - let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); - let authority = authority.rsplit('@').next().unwrap_or(authority); - let host = if let Some(bracketed) = authority.strip_prefix('[') { - bracketed.split(']').next().unwrap_or("") - } else { - authority.split(':').next().unwrap_or("") - }; - let host = host.to_ascii_lowercase(); - // A literal IP must actually be loopback ("127.evil.com" is a valid DNS - // name pointing anywhere, so prefix matching would be a hole). - if let Ok(ip) = host.parse::() { - return ip.is_loopback(); - } - host == "localhost" +/// Canonical plain-language caution for login-free mode (`[auth] required = +/// false`). LIFIC-22: this is the single source of truth that both the `lific +/// init` auth-mode menu and the `lific start` startup warning consume, so the +/// two surfaces can never diverge. It names the risk, states the safe +/// condition, and gives the recovery path — no shock all-caps language, no +/// internal jargon like "credential-less request". +pub fn login_free_caution() -> &'static str { + "LIFIC is running in login-free mode: anyone who can reach it can administer \ + it. Keep it on a machine only you and trusted people can reach. To switch \ + to passwords, set [auth] required = true and run lific init again." +} + +/// The two auth modes an operator can choose at `lific init` (LIFIC-25). +/// +/// A single conceptual thing — "the auth mode" — bundles every consequence of +/// the choice into one type, so the menu and `cmd_init` never drift on how the +/// mode maps to config (`[auth] required`, `[server] host`), the database +/// (`web_auto_login`), and admin creation (passwordless vs passworded). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthMode { + /// No password; browser auto-login as the operator; binds loopback. + LoginFree, + /// Password sign-in on the web; leaves the bind host unchanged. + Passwords, +} + +impl AuthMode { + /// `[auth] required`: login-free turns auth off; passwords keeps it on. + pub fn required(self) -> bool { + matches!(self, AuthMode::Passwords) + } + + /// The `[server] host` to write, or `None` to leave it unchanged. + /// Login-free must bind loopback so the startup guard (LIFIC-24) and the + /// actual listening socket agree; password mode never touches it. + pub fn host(self) -> Option<&'static str> { + match self { + AuthMode::LoginFree => Some("127.0.0.1"), + AuthMode::Passwords => None, + } + } + + /// The `instance_settings.web_auto_login` flag: on for login-free so the + /// browser signs the operator in without a password. + pub fn web_auto_login(self) -> bool { + matches!(self, AuthMode::LoginFree) + } + + /// Whether the first admin is created passwordless (login-free) or with a + /// real password (passwords). + pub fn passwordless(self) -> bool { + matches!(self, AuthMode::LoginFree) + } + + /// The stable string used for the `--auth-mode` CLI flag and menu labels. + pub fn as_str(self) -> &'static str { + match self { + AuthMode::LoginFree => "login-free", + AuthMode::Passwords => "passwords", + } + } + + /// Parse a `--auth-mode` flag value, case-insensitive. + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "login-free" => Some(AuthMode::LoginFree), + "passwords" => Some(AuthMode::Passwords), + _ => None, + } + } +} + +/// Does a `[server] host` bind value point at the local machine? Backs the +/// LIFIC-24 startup guard: login-free mode must refuse to bind anywhere but +/// loopback, so the safety check and the actual listening socket agree. +/// Conservative — anything unparseable counts as NOT loopback. +pub fn is_localhost_host(host: &str) -> bool { + let host = host.trim(); + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()) } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -313,6 +375,40 @@ impl Config { toml::to_string_pretty(&cfg).unwrap_or_default() } + /// Merge the auth-mode menu's choice into a config document, preserving + /// every other section and setting. + /// + /// LIFIC-23: sets `[auth] required` and, when `host` is supplied, + /// `[server] host`. When `existing` is empty/absent the function builds a + /// fresh default config carrying the chosen values; otherwise it edits the + /// existing TOML in place (formatting and comments survive via toml_edit). + /// Pure — no filesystem side effects. This is what the `lific init` + /// auth-mode menu (LIFIC-25) uses to persist the operator's choice. + /// + /// Returns `Err` (leaving the caller with the untouched source to fix by + /// hand) when an existing document does not parse — never destroys a + /// user's config, mirroring the connect writers. + pub fn apply_auth_mode( + existing: &str, + required: bool, + host: Option<&str>, + ) -> Result { + let mut doc: toml_edit::DocumentMut = if existing.trim().is_empty() { + Config::default_toml() + .parse::() + .expect("default config parses") + } else { + existing + .parse() + .map_err(|e| format!("existing config does not parse: {e}"))? + }; + doc["auth"]["required"] = toml_edit::value(required); + if let Some(host) = host { + doc["server"]["host"] = toml_edit::value(host); + } + Ok(doc.to_string()) + } + /// Resolve the backup directory relative to the database path if not absolute. pub fn backup_dir(&self) -> PathBuf { if self.backup.dir.is_absolute() { @@ -559,29 +655,127 @@ trusted_proxies = ["not-a-cidr"] assert!(cfg.auth.required); } - // LIF-294: the startup guard's localhost check. + // LIFIC-22: the shared login-free caution is set once and plain-language. + #[test] + fn login_free_caution_is_plain_and_complete() { + let text = login_free_caution(); + // Names the mode. + assert!(text.contains("login-free mode")); + // States the risk in plain words. + assert!(text.contains("anyone who can reach it can administer it")); + // States the safe condition. + assert!(text.contains("Keep it on a machine only you and trusted people can reach")); + // States the recovery path. + assert!(text.contains("required = true")); + // No shock all-caps language is allowed to leak back in from the old + // "AUTH IS DISABLED" warning. + assert!(!text.contains("AUTH IS DISABLED")); + // No internal jargon either. + assert!(!text.contains("credential-less")); + } + + // LIFIC-23: applying the auth-mode choice edits in place and preserves + // every other section, setting, and comment. + #[test] + fn apply_auth_mode_edits_required_and_host_and_preserves_sections() { + let existing = r#"# my cruft +[server] +host = "0.0.0.0" +port = 3456 + +[auth] +required = true +allow_signup = false + +[backup] +enabled = false +"#; + let out = Config::apply_auth_mode(existing, false, Some("127.0.0.1")).unwrap(); + // Comment survives. + assert!(out.contains("# my cruft"), "comment must survive"); + // Our two values are set. + let doc: toml_edit::DocumentMut = out.parse().unwrap(); + assert_eq!(doc["auth"]["required"].as_bool(), Some(false)); + assert_eq!(doc["server"]["host"].as_str(), Some("127.0.0.1")); + // Untouched siblings survive with their values. + assert_eq!(doc["server"]["port"].as_integer(), Some(3456)); + assert_eq!(doc["auth"]["allow_signup"].as_bool(), Some(false)); + assert_eq!(doc["backup"]["enabled"].as_bool(), Some(false)); + } + + // LIFIC-23: password mode only touches required, leaving host untouched. + #[test] + fn apply_auth_mode_password_leaves_host_alone() { + let existing = "[server]\nhost = \"0.0.0.0\"\nport = 9000\n\n[auth]\nrequired = false\n"; + let out = Config::apply_auth_mode(existing, true, None).unwrap(); + let doc: toml_edit::DocumentMut = out.parse().unwrap(); + assert_eq!(doc["auth"]["required"].as_bool(), Some(true)); + assert_eq!(doc["server"]["host"].as_str(), Some("0.0.0.0")); + assert_eq!(doc["server"]["port"].as_integer(), Some(9000)); + } + + // LIFIC-23: an absent/empty document produces a fresh default config. #[test] - fn is_localhost_url_accepts_only_loopback() { - for url in [ - "http://localhost:3456", - "http://localhost", - "https://LOCALHOST/lific", - "http://127.0.0.1:3456", - "http://127.5.5.5", - "http://[::1]:3456", - "http://user@localhost:3456/path", + fn apply_auth_mode_creates_fresh_when_absent() { + let out = Config::apply_auth_mode("", false, Some("127.0.0.1")).unwrap(); + let doc: toml_edit::DocumentMut = out.parse().unwrap(); + assert_eq!(doc["auth"]["required"].as_bool(), Some(false)); + assert_eq!(doc["server"]["host"].as_str(), Some("127.0.0.1")); + // Defaults still present. + assert_eq!(doc["server"]["port"].as_integer(), Some(3456)); + assert_eq!(doc["auth"]["allow_signup"].as_bool(), Some(true)); + } + + // LIFIC-23: an unparseable existing document must not be destroyed — the + // editor refuses and returns an error, mirroring the connect writers. + #[test] + fn apply_auth_mode_refuses_unparseable_and_returns_error() { + let existing = "this is = = not valid toml [[[\n"; + let err = Config::apply_auth_mode(existing, false, Some("127.0.0.1")).unwrap_err(); + assert!(err.contains("does not parse"), "error must say why: {err}"); + } + + // LIFIC-24: the startup guard's bind-host check. + #[test] + fn is_localhost_host_accepts_only_loopback() { + for host in [ + "127.0.0.1", + "127.5.5.5", + "::1", + "localhost", + "LOCALHOST", ] { - assert!(is_localhost_url(url), "{url} should count as localhost"); + assert!(is_localhost_host(host), "{host} should count as loopback"); } - for url in [ - "https://lific.tail1234.ts.net", - "http://192.168.1.10:3456", - "http://127.evil.com", // DNS name, not a loopback IP - "https://localhost.example", // ditto - "http://[::2]", - "", - ] { - assert!(!is_localhost_url(url), "{url} must NOT count as localhost"); + for host in ["0.0.0.0", "::", "[::]", "192.168.1.10", "lific.example", ""] { + assert!(!is_localhost_host(host), "{host} must NOT count as loopback"); } } + + // LIFIC-25: the auth-mode menu's two choices bundle `(required, host, + // web_auto_login, admin-passwordless)` into one concept. + #[test] + fn auth_mode_bundles_its_consequences() { + let free = AuthMode::LoginFree; + assert!(!free.required()); + assert_eq!(free.host(), Some("127.0.0.1")); + assert!(free.web_auto_login()); + assert!(free.passwordless()); + + let pw = AuthMode::Passwords; + assert!(pw.required()); + assert_eq!(pw.host(), None, "password mode leaves host unchanged"); + assert!(!pw.web_auto_login()); + assert!(!pw.passwordless()); + } + + #[test] + fn auth_mode_parses_and_names() { + assert_eq!(AuthMode::parse("login-free"), Some(AuthMode::LoginFree)); + assert_eq!(AuthMode::parse("passwords"), Some(AuthMode::Passwords)); + assert_eq!(AuthMode::parse("LOGIN-FREE"), Some(AuthMode::LoginFree)); + assert_eq!(AuthMode::parse("bogus"), None); + assert_eq!(AuthMode::LoginFree.as_str(), "login-free"); + assert_eq!(AuthMode::Passwords.as_str(), "passwords"); + } } diff --git a/src/db/migrate.rs b/src/db/migrate.rs index 80ed7c31..1967e79a 100644 --- a/src/db/migrate.rs +++ b/src/db/migrate.rs @@ -170,6 +170,16 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ "project groups", include_str!("../../migrations/035_project_groups.sql"), ), + ( + 36, + "oauth client tool", + include_str!("../../migrations/036_oauth_client_tool.sql"), + ), + ( + 37, + "users tool id", + include_str!("../../migrations/037_users_tool_id.sql"), + ), ]; /// Highest migration version this binary knows how to apply. Used by diff --git a/src/db/models.rs b/src/db/models.rs index 322ffad1..cc98d8a3 100644 --- a/src/db/models.rs +++ b/src/db/models.rs @@ -496,7 +496,7 @@ pub struct LoginRequest { /// Lightweight user identity extracted from auth middleware. /// Inserted into request extensions after token resolution. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthUser { pub id: i64, pub username: String, @@ -522,8 +522,10 @@ pub struct Bot { pub display_name: String, pub owner_id: Option, pub created_at: String, - /// Whether the bot has an active (non-revoked) API key. - pub has_active_key: bool, + /// Whether the bot has any live credential (an active API key or an active + /// OAuth token). Used by the Connected Tools UI to show connected state, + /// independent of *how* the bot was connected (LIFIC-13 OAuth vs lific connect key). + pub connected: bool, } // ── API Key (user-facing) ──────────────────────────────────── diff --git a/src/db/queries/users.rs b/src/db/queries/users.rs index 5748eed3..2dc8e440 100644 --- a/src/db/queries/users.rs +++ b/src/db/queries/users.rs @@ -292,6 +292,121 @@ pub fn first_admin(conn: &Connection) -> Result, LificError> { } } +// ── Passwordless admin (LIFIC-9) ──────────────────────────── + +/// Derive a usable, unique username from a display name. Keeps [a-z0-9-], +/// collapses runs of non-alphanumerics to a single `-`, and falls back to +/// `admin` if nothing survives; appends `-N` when the raw slug is taken. +fn derive_username(conn: &Connection, display_name: &str) -> Result { + let slug: String = display_name + .to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + let base = if slug.is_empty() { "admin".to_string() } else { slug }; + let mut candidate = base.clone(); + let mut n = 1; + while get_user_by_username(conn, &candidate).is_ok() { + candidate = format!("{base}-{n}"); + n += 1; + } + Ok(candidate) +} + +/// Create the first human admin on a fresh install — a passwordless operator. +/// +/// "Passwordless" means it can never be signed into by password: the stored +/// hash is a random value with no known plaintext, and the email is a synthetic +/// placeholder that satisfies the NOT NULL UNIQUE schema. The operator reaches +/// this identity through the browser auto-login / passwordless fallback in +/// `resolve_caller`, never through a password prompt. +/// +/// LIFIC-9: this is what makes `[auth] required = false` "passwordless mode" +/// instead of "half-broken anonymous" — there is always a real admin to resolve +/// to from the moment the instance exists. +pub fn create_passwordless_admin( + conn: &Connection, + display_name: &str, +) -> Result { + let display_name = display_name.trim(); + if display_name.is_empty() { + return Err(LificError::BadRequest( + "operator name cannot be empty".into(), + )); + } + // Unusable hash: never arithmetically a login password, just fills the NOT + // NULL column. Same guarantee as `create_bot_user`. + let password_hash = unusable_password_hash()?; + insert_first_admin(conn, display_name, password_hash) +} + +/// Create the first human admin with a real password — the `Passwords` mode of +/// the `lific init` auth-mode menu (LIFIC-25). +/// +/// Same username/email derivation as [`create_passwordless_admin`], but the +/// stored hash is a real argon2 hash of `password`, so the operator can sign in +/// on the web. This is the counterpart to passwordless mode: the operator +/// still reaches the instance without an admin prompt, but through the password +/// gate rather than browser auto-login. +pub fn create_first_admin_with_password( + conn: &Connection, + display_name: &str, + password: &str, +) -> Result { + let display_name = display_name.trim(); + if display_name.is_empty() { + return Err(LificError::BadRequest( + "operator name cannot be empty".into(), + )); + } + if password.is_empty() { + return Err(LificError::BadRequest( + "operator password cannot be empty".into(), + )); + } + let password_hash = hash_password(password)?; + insert_first_admin(conn, display_name, password_hash) +} + +/// Shared insert for the first human admin (LIFIC-22/25). Derives the unique +/// username from `display_name`, fills the NOT NULL email with a synthetic +/// `{username}@local` placeholder, and stores the given `password_hash`. Both +/// passwordless mode (unusable hash) and password mode (real argon2 hash) land +/// here, so the derivation and constraint handling live in exactly one place. +fn insert_first_admin( + conn: &Connection, + display_name: &str, + password_hash: String, +) -> Result { + let username = derive_username(conn, display_name)?; + + conn.execute( + "INSERT INTO users (username, email, password_hash, display_name, is_admin, is_bot) + VALUES (?1, ?2, ?3, ?4, 1, 0)", + params![ + username, + format!("{username}@local"), + password_hash, + display_name, + ], + ) + .map_err(|e| match e { + rusqlite::Error::SqliteFailure(err, _) + if err.code == rusqlite::ErrorCode::ConstraintViolation => + { + LificError::Internal("failed to create first admin (constraint)".into()) + } + other => other.into(), + })?; + + let id = conn.last_insert_rowid(); + get_user_by_id(conn, id) +} + // ── Sessions ───────────────────────────────────────────────── /// Hash a session token with SHA-256 for storage. @@ -433,16 +548,24 @@ pub fn get_user_for_api_key(conn: &Connection, key_id: i64) -> Result Result { + let random_pw: [u8; 32] = rand::random(); + let random_pw_hex: String = random_pw.iter().map(|b| format!("{b:02x}")).collect(); + hash_password(&random_pw_hex) +} + pub fn create_bot_user( conn: &Connection, owner_id: i64, bot_username: &str, display_name: &str, ) -> Result { - // Bot users get a random password (never used for login) - let random_pw: [u8; 32] = rand::random(); - let random_pw_hex: String = random_pw.iter().map(|b| format!("{b:02x}")).collect(); - let password_hash = hash_password(&random_pw_hex)?; + let password_hash = unusable_password_hash()?; conn.execute( "INSERT INTO users (username, email, password_hash, display_name, is_admin, is_bot, owner_id) @@ -482,15 +605,46 @@ pub fn set_admin(conn: &Connection, username: &str, is_admin: bool) -> Result<() Ok(()) } -/// Find a bot user by username (for reconnection checks). -pub fn find_bot_by_username( +/// Find a bot by its stable (owner, tool) pairing (LIFIC-17). +/// +/// This is the key that survives an owner rename, where the derived +/// `{tool}-{owner.username}` username does not. +pub fn find_bot_by_owner_and_tool( conn: &Connection, - username: &str, + owner_id: i64, + tool_id: &str, ) -> Result, LificError> { match conn.query_row( "SELECT id, username, email, password_hash, display_name, is_admin, is_bot, created_at, updated_at - FROM users WHERE username = ?1 AND is_bot = 1", - params![username], + FROM users WHERE owner_id = ?1 AND tool_id = ?2 AND is_bot = 1 LIMIT 1", + params![owner_id, tool_id], + row_to_user, + ) { + Ok(user) => Ok(Some(user)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } +} + +/// Find a legacy bot (tool_id NULL, minted before LIFIC-17) by its owner and +/// its tool *prefix*. +/// +/// Legacy bots were keyed by the `{tool}-{owner.username}` username, which +/// embeds the owner's name at mint time. After a rename that prefix is stale, +/// so the lookup must not depend on the current owner username — it matches on +/// the stable `owner_id` and the tool prefix alone, which a rename never +/// touches. `GLOB '{tool_id}-*'` ties the match to the exact tool prefix (tool +/// slugs are `[a-z0-9-]`, so no `*`/`?` need escaping). +pub fn find_bot_legacy_by_tool_prefix( + conn: &Connection, + owner_id: i64, + tool_id: &str, +) -> Result, LificError> { + match conn.query_row( + "SELECT id, username, email, password_hash, display_name, is_admin, is_bot, created_at, updated_at + FROM users WHERE owner_id = ?1 AND is_bot = 1 AND tool_id IS NULL + AND username GLOB ?2 LIMIT 1", + params![owner_id, format!("{tool_id}-*")], row_to_user, ) { Ok(user) => Ok(Some(user)), @@ -500,10 +654,17 @@ pub fn find_bot_by_username( } /// Check if a bot has any active (non-revoked) API keys. -pub fn bot_has_active_key(conn: &Connection, bot_id: i64) -> Result { +/// Whether a bot has standing access — an active (non-revoked) API key, or a +/// non-revoked OAuth token. Mirrors the Connected Tools "connected" state +/// (LIFIC-13): access is granted until explicitly revoked/disconnected, +/// independent of OAuth token expiry (the agent self-heals via re-auth). +/// Used to refuse re-connecting a tool that's already connected via either door. +pub fn bot_is_connected(conn: &Connection, bot_id: i64) -> Result { let has: bool = conn .query_row( - "SELECT COUNT(*) > 0 FROM api_keys WHERE user_id = ?1 AND revoked = 0", + "SELECT + EXISTS(SELECT 1 FROM api_keys WHERE user_id = ?1 AND revoked = 0) + OR EXISTS(SELECT 1 FROM oauth_tokens WHERE user_id = ?1 AND revoked = 0)", params![bot_id], |row| row.get(0), ) @@ -511,6 +672,50 @@ pub fn bot_has_active_key(conn: &Connection, bot_id: i64) -> Result Result { + // Structured dedupe first: stable across owner renames. + if let Some(existing) = find_bot_by_owner_and_tool(conn, owner_id, tool_id)? { + return Ok(existing); + } + // Legacy bot: pre-migration, tool_id NULL, keyed only by owner + tool. + // Reuse and backfill it. + if let Some(legacy) = find_bot_legacy_by_tool_prefix(conn, owner_id, tool_id)? { + conn.execute( + "UPDATE users SET tool_id = ?1 WHERE id = ?2", + params![tool_id, legacy.id], + )?; + return Ok(legacy); + } + let owner_username = get_user_by_id(conn, owner_id)?.username; + let bot_username = format!("{tool_id}-{owner_username}"); + let bot = create_bot_user(conn, owner_id, &bot_username, display_name)?; + conn.execute( + "UPDATE users SET tool_id = ?1 WHERE id = ?2", + params![tool_id, bot.id], + )?; + Ok(bot) +} + /// List all bots owned by a specific user. pub fn list_bots( conn: &Connection, @@ -518,7 +723,11 @@ pub fn list_bots( ) -> Result, LificError> { let mut stmt = conn.prepare_cached( "SELECT u.id, u.username, u.display_name, u.owner_id, u.created_at, - EXISTS(SELECT 1 FROM api_keys k WHERE k.user_id = u.id AND k.revoked = 0) as has_key + EXISTS( + SELECT 1 FROM api_keys k WHERE k.user_id = u.id AND k.revoked = 0 + UNION + SELECT 1 FROM oauth_tokens t WHERE t.user_id = u.id AND t.revoked = 0 + ) as connected FROM users u WHERE u.is_bot = 1 AND u.owner_id = ?1 ORDER BY u.created_at DESC", @@ -530,20 +739,22 @@ pub fn list_bots( display_name: row.get(2)?, owner_id: row.get(3)?, created_at: row.get(4)?, - has_active_key: row.get(5)?, + connected: row.get(5)?, }) })?; rows.collect::, _>>().map_err(Into::into) } -/// Disconnect a bot: revoke its API key(s). Only the owner or admin can do this. -pub fn disconnect_bot( +/// Verify a bot both exists and is owned by `requester_id` (or the requester +/// is admin). Returns the bot's id on success. Shared by [`disconnect_bot`] +/// and [`delete_bot`], whose ownership rules are identical. +fn verify_bot_owner( conn: &Connection, bot_id: i64, requester_id: i64, is_admin: bool, + action: &str, ) -> Result<(), LificError> { - // Verify ownership let owner_id: Option = conn .query_row( "SELECT owner_id FROM users WHERE id = ?1 AND is_bot = 1", @@ -553,45 +764,56 @@ pub fn disconnect_bot( .map_err(|_| LificError::NotFound("bot not found".into()))?; if owner_id != Some(requester_id) && !is_admin { - return Err(LificError::BadRequest( - "you can only disconnect your own bots".into(), - )); + return Err(LificError::BadRequest(format!( + "you can only {action} your own bots" + ))); } + Ok(()) +} + +/// Disconnect a bot: revoke its credentials (API keys and OAuth tokens) so the +/// bot can no longer act. The bot's identity is kept — reconnecting later +/// reuses it. Only the owner or admin can do this. +pub fn disconnect_bot( + conn: &Connection, + bot_id: i64, + requester_id: i64, + is_admin: bool, +) -> Result<(), LificError> { + verify_bot_owner(conn, bot_id, requester_id, is_admin, "disconnect")?; // Revoke all API keys for this bot conn.execute( "UPDATE api_keys SET revoked = 1 WHERE user_id = ?1 AND revoked = 0", params![bot_id], )?; + // Revoke all OAuth tokens for this bot (LIFIC-13 follow-up): an + // OAuth-connected agent has no API key, so without this "Disconnect" + // would leave its access token live. Rows are kept — reconnectable bot. + conn.execute( + "UPDATE oauth_tokens SET revoked = 1 WHERE user_id = ?1 AND revoked = 0", + params![bot_id], + )?; Ok(()) } -/// Permanently delete a bot user and all its API keys. -/// Only the owner or an admin can do this. +/// Permanently delete a bot user, its API keys, its OAuth tokens, and the +/// comments it made. The identity is gone, so any OAuth token rows are shred +/// rather than revoked. Only the owner or an admin can do this. pub fn delete_bot( conn: &Connection, bot_id: i64, requester_id: i64, is_admin: bool, ) -> Result<(), LificError> { - // Verify ownership - let owner_id: Option = conn - .query_row( - "SELECT owner_id FROM users WHERE id = ?1 AND is_bot = 1", - params![bot_id], - |row| row.get(0), - ) - .map_err(|_| LificError::NotFound("bot not found".into()))?; - - if owner_id != Some(requester_id) && !is_admin { - return Err(LificError::BadRequest( - "you can only delete your own bots".into(), - )); - } + verify_bot_owner(conn, bot_id, requester_id, is_admin, "delete")?; // Delete API keys first (FK constraint) conn.execute("DELETE FROM api_keys WHERE user_id = ?1", params![bot_id])?; + // Delete the bot's OAuth tokens (LIFIC-13 follow-up): leaves no dangling + // rows pointing at a removed identity. + conn.execute("DELETE FROM oauth_tokens WHERE user_id = ?1", params![bot_id])?; // Delete any comments made by this bot (or reassign — deleting for now) conn.execute("DELETE FROM comments WHERE user_id = ?1", params![bot_id])?; @@ -717,6 +939,304 @@ mod tests { ); } + // ── ensure_bot (LIFIC-13) ──────────────────────────────── + + #[test] + fn ensure_bot_creates_a_new_bot_for_the_owner() { + let pool = test_db(); + let conn = pool.write().unwrap(); + let owner = test_create_user(&conn); + + let bot_id = ensure_bot(&conn, owner.id, "claude-code", "Claude Code") + .unwrap() + .id; + let bot = get_user_by_id(&conn, bot_id).unwrap(); + assert!(bot.is_bot, "minted user is a bot"); + assert_eq!(bot.username, "claude-code-blake"); + assert_eq!(bot.display_name, "Claude Code"); + let listed = list_bots(&conn, owner.id).unwrap(); + assert_eq!(listed.len(), 1, "one bot owned by this user"); + assert_eq!(listed[0].owner_id, Some(owner.id)); + } + + #[test] + fn ensure_bot_reuses_existing_bot_for_same_tool_and_owner() { + let pool = test_db(); + let conn = pool.write().unwrap(); + let owner = test_create_user(&conn); + + let first = ensure_bot(&conn, owner.id, "opencode", "OpenCode").unwrap().id; + let second = ensure_bot(&conn, owner.id, "opencode", "OpenCode").unwrap().id; + assert_eq!(first, second, "re-approval must reuse, not duplicate"); + } + + #[test] + fn ensure_bot_distinguishes_owners() { + let pool = test_db(); + let conn = pool.write().unwrap(); + let owner_a = test_create_user(&conn); + let owner_b = create_user( + &conn, + &CreateUser { + username: "ada".into(), + email: "ada@example.com".into(), + password: "securepassword123".into(), + display_name: None, + is_admin: false, + is_bot: false, + }, + ) + .unwrap(); + + let a = ensure_bot(&conn, owner_a.id, "opencode", "OpenCode").unwrap().id; + let b = ensure_bot(&conn, owner_b.id, "opencode", "OpenCode").unwrap().id; + assert_ne!(a, b, "each owner gets its own bot for the same tool"); + } + + // ── stable dedupe across owner rename (LIFIC-17) ────────── + + // The bot identity is keyed on (owner_id, tool_id), not the derived + // `{tool}-{owner}` username string. Renaming the owner changes the string + // but not the (owner_id, tool_id) pair, so a re-connect must reuse the + // original bot rather than mint a duplicate. + #[test] + fn ensure_bot_reuses_existing_bot_after_owner_rename() { + let pool = test_db(); + let conn = pool.write().unwrap(); + let owner = test_create_user(&conn); // username "blake" + + let first = ensure_bot(&conn, owner.id, "opencode", "OpenCode").unwrap().id; + + // Simulate the owner renaming their account: username changes, id stays. + conn.execute( + "UPDATE users SET username = ?1 WHERE id = ?2", + params!["renamed-blake", owner.id], + ) + .unwrap(); + + let second = ensure_bot(&conn, owner.id, "opencode", "OpenCode").unwrap().id; + assert_eq!(first, second, "renaming the owner must not orphan the agent"); + } + + // Bots minted before the tool_id column existed (tool_id NULL) are still + // found by their legacy `{tool}-{owner}` username and backfilled, so an + // existing install does not duplicate agents on the first post-upgrade + // reconnect. + #[test] + fn ensure_bot_backfills_legacy_bot_by_username() { + let pool = test_db(); + let conn = pool.write().unwrap(); + let owner = test_create_user(&conn); // username "blake" + // A pre-migration bot: username "opencode-blake", tool_id NULL. + let legacy = create_bot_user(&conn, owner.id, "opencode-blake", "OpenCode") + .unwrap(); + + let reused = ensure_bot(&conn, owner.id, "opencode", "OpenCode").unwrap(); + assert_eq!( + reused.id, legacy.id, + "a legacy bot keyed by username must be reused, not duplicated" + ); + let stored: Option = conn + .query_row( + "SELECT tool_id FROM users WHERE id = ?1", + params![legacy.id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(stored.as_deref(), Some("opencode"), "legacy bot tool_id backfilled"); + } + + // The legacy backfill holds even when the owner renamed *before* the + // reconnect: the legacy username embeds the old owner name, so the match + // keys on owner id + tool prefix, never the current owner username. + #[test] + fn ensure_bot_backfills_legacy_bot_even_after_owner_rename() { + let pool = test_db(); + let conn = pool.write().unwrap(); + let owner = test_create_user(&conn); // username "blake" + // A pre-migration bot whose username still has the old owner name. + let legacy = create_bot_user(&conn, owner.id, "opencode-oldname", "OpenCode") + .unwrap(); + // The owner renames before ever reconnecting. + conn.execute( + "UPDATE users SET username = ?1 WHERE id = ?2", + params!["new-name", owner.id], + ) + .unwrap(); + + let reused = ensure_bot(&conn, owner.id, "opencode", "OpenCode").unwrap(); + assert_eq!( + reused.id, legacy.id, + "renaming before a legacy reconnect must still reuse, not duplicate" + ); + } + + // ── disconnect/delete bot credential revocation (LIFIC-13 follow-up) ── + + /// Insert an active (non-revoked) `oauth_tokens` row bound to `user_id`. + fn insert_oauth_token_for(conn: &Connection, user_id: i64) -> i64 { + let token_hash = format!("testtoken-{user_id}-{}", user_id); + let client_id = "test-client"; + conn.execute( + "INSERT INTO oauth_clients (client_id, client_name, redirect_uris) VALUES (?1, 'Test', '[\"http://localhost\"]')", + params![client_id], + ) + .unwrap(); + conn.execute( + "INSERT INTO oauth_tokens (access_token, client_id, expires_at, scope, user_id) + VALUES (?1, ?2, datetime('now', '+1 hour'), 'mcp', ?3)", + params![token_hash, client_id, user_id], + ) + .unwrap(); + let id: i64 = conn + .query_row( + "SELECT rowid FROM oauth_tokens WHERE access_token = ?1", + params![token_hash], + |r| r.get(0), + ) + .unwrap(); + id + } + + #[test] + fn disconnect_bot_revokes_bots_oauth_tokens_but_keeps_bot() { + let pool = test_db(); + let conn = pool.write().unwrap(); + let owner = test_create_user(&conn); + let bot = ensure_bot(&conn, owner.id, "claude-code", "Claude Code").unwrap(); + insert_oauth_token_for(&conn, bot.id); + + disconnect_bot(&conn, bot.id, owner.id, false).unwrap(); + + // The bot and its OAuth tokens still exist (reconnectable), but tokens revoked. + let revoked: usize = conn + .query_row( + "SELECT COUNT(*) FROM oauth_tokens WHERE user_id = ?1 AND revoked = 1", + params![bot.id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(revoked, 1, "bot's OAuth token revoked"); + let still_there: usize = conn + .query_row( + "SELECT COUNT(*) FROM oauth_tokens WHERE user_id = ?1", + params![bot.id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(still_there, 1, "token row kept — reconnectable bot"); + let bot_exists: usize = conn + .query_row( + "SELECT COUNT(*) FROM users WHERE id = ?1 AND is_bot = 1", + params![bot.id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(bot_exists, 1, "bot identity kept after disconnect"); + } + + #[test] + fn delete_bot_removes_its_oauth_token_rows() { + let pool = test_db(); + let conn = pool.write().unwrap(); + let owner = test_create_user(&conn); + let bot = ensure_bot(&conn, owner.id, "opencode", "OpenCode").unwrap(); + insert_oauth_token_for(&conn, bot.id); + + delete_bot(&conn, bot.id, owner.id, false).unwrap(); + + let tokens: usize = conn + .query_row( + "SELECT COUNT(*) FROM oauth_tokens WHERE user_id = ?1", + params![bot.id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(tokens, 0, "delete shreds the bot's OAuth token rows"); + let bot_rows: usize = conn + .query_row( + "SELECT COUNT(*) FROM users WHERE id = ?1", + params![bot.id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(bot_rows, 0, "bot identity removed"); + } + + // ── list_bots / connected semantics (LIFIC-13 OAuth bots) ── + + #[test] + fn bot_with_oauth_token_lists_as_connected() { + let pool = test_db(); + let conn = pool.write().unwrap(); + let owner = test_create_user(&conn); + let bot = ensure_bot(&conn, owner.id, "opencode", "OpenCode").unwrap(); + // No API key — connected purely by an OAuth token (LIFIC-13 path). + insert_oauth_token_for(&conn, bot.id); + + let listed = list_bots(&conn, owner.id).unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, bot.id); + assert!( + listed[0].connected, + "an OAuth-connected bot must list as connected (no API key involved)" + ); + assert!(bot_is_connected(&conn, bot.id).unwrap()); + } + + #[test] + fn bot_with_only_revoked_credentials_lists_as_disconnected() { + let pool = test_db(); + let conn = pool.write().unwrap(); + let owner = test_create_user(&conn); + let bot = ensure_bot(&conn, owner.id, "opencode", "OpenCode").unwrap(); + insert_oauth_token_for(&conn, bot.id); + // Revoke the token — the bot is no longer connected. + conn.execute( + "UPDATE oauth_tokens SET revoked = 1 WHERE user_id = ?1", + params![bot.id], + ) + .unwrap(); + + let listed = list_bots(&conn, owner.id).unwrap(); + assert!( + !listed[0].connected, + "a bot with only revoked credentials must list as disconnected" + ); + assert!(!bot_is_connected(&conn, bot.id).unwrap()); + } + + #[test] + fn api_key_connected_bot_still_lists_as_connected() { + let pool = test_db(); + let (owner, bot) = { + let conn = pool.write().unwrap(); + let owner = test_create_user(&conn); + let bot = ensure_bot(&conn, owner.id, "claude-code", "Claude Code").unwrap(); + (owner.id, bot.id) + }; + // The classic `lific connect` path: an active API key, no OAuth token. + let name = format!("claude-code-{}", { + let conn = pool.read().unwrap(); + get_user_by_id(&conn, owner).unwrap().username + }); + let manager = crate::auth::create_key_manager().unwrap(); + let _ = crate::auth::create_api_key(&pool, &manager, &name).unwrap(); + { + let conn = pool.write().unwrap(); + crate::db::queries::users::assign_key_to_user(&conn, &name, bot).unwrap(); + } + + let listed = { + let conn = pool.read().unwrap(); + list_bots(&conn, owner).unwrap() + }; + assert!( + listed.iter().any(|b| b.id == bot && b.connected), + "API-key-connected bot (legacy path) still lists as connected" + ); + } + // ── LIF-190: profile + password updates ───────────────── #[test] @@ -1109,4 +1629,99 @@ mod tests { let result = assign_key_to_user(&conn, "nope", user.id); assert!(result.is_err()); } + + // ── create_passwordless_admin (LIFIC-9) ───────────────── + + #[test] + fn operator_admin_is_not_a_connected_tool() { + let pool = test_db(); + let conn = pool.write().unwrap(); + let admin = create_passwordless_admin(&conn, "Operator Blake").unwrap(); + + assert!(admin.is_admin, "first admin is an admin"); + assert!(!admin.is_bot, "first admin is a person, not a connected tool"); + assert_eq!(admin.display_name, "Operator Blake"); + } + + #[test] + fn operator_admin_resolves_as_first_admin() { + let pool = test_db(); + let conn = pool.write().unwrap(); + let admin = create_passwordless_admin(&conn, "Operator Blake").unwrap(); + + let resolved = first_admin(&conn).unwrap().expect("resolves as first admin"); + assert_eq!(resolved.id, admin.id); + assert_eq!(resolved.username, admin.username); + } + + #[test] + fn operator_username_comes_from_their_name() { + let pool = test_db(); + let conn = pool.write().unwrap(); + let admin = create_passwordless_admin(&conn, "Blake Smith").unwrap(); + assert_eq!(admin.username, "blake-smith"); + } + + #[test] + fn same_named_operators_get_distinct_usernames() { + let pool = test_db(); + let conn = pool.write().unwrap(); + let first = create_passwordless_admin(&conn, "Blake").unwrap(); + let second = create_passwordless_admin(&conn, "blake!").unwrap(); + + assert_ne!(first.username, second.username, "usernames must not collide"); + assert!(!first.username.is_empty()); + assert!(!second.username.is_empty()); + } + + #[test] + fn passwordless_admin_cannot_be_logged_into_by_password() { + let pool = test_db(); + let conn = pool.write().unwrap(); + create_passwordless_admin(&conn, "Blake").unwrap(); + // The random stored hash has no known plaintext, so password login + // must always fail — there is no password, only passwordless identity. + let result = authenticate(&conn, "blake", "anypassword123"); + assert!( + result.is_err(), + "passwordless admin must never authenticate by password" + ); + } + + // ── create_first_admin_with_password (LIFIC-25) ────────── + + #[test] + fn password_admin_is_admin_and_authenticates() { + let pool = test_db(); + let conn = pool.write().unwrap(); + let admin = create_first_admin_with_password(&conn, "Blake Smith", "hunter22").unwrap(); + + assert!(admin.is_admin, "first admin is an admin"); + assert_eq!(admin.username, "blake-smith"); + assert!(!admin.is_bot); + let got = authenticate(&conn, "blake-smith", "hunter22").unwrap(); + assert_eq!(got.id, admin.id, "correct password logs in as the admin"); + } + + #[test] + fn password_admin_rejects_wrong_password() { + let pool = test_db(); + let conn = pool.write().unwrap(); + create_first_admin_with_password(&conn, "Blake", "correcthorse1").unwrap(); + assert!( + authenticate(&conn, "blake", "wrongpassword").is_err(), + "wrong password must be rejected" + ); + } + + #[test] + fn password_admin_rejects_empty_password() { + let pool = test_db(); + let conn = pool.write().unwrap(); + let err = create_first_admin_with_password(&conn, "Blake", "").unwrap_err(); + assert!( + matches!(err, LificError::BadRequest(_)), + "an empty password must be rejected, got {err:?}" + ); + } } diff --git a/src/import/mod.rs b/src/import/mod.rs index 2771801b..44babfb2 100644 --- a/src/import/mod.rs +++ b/src/import/mod.rs @@ -147,16 +147,9 @@ pub fn ensure_import_bot( source_slug: &str, display: &str, ) -> Result { - let owner_username = { - let conn = pool.read()?; - queries::users::get_user_by_id(&conn, owner_id)?.username - }; - let bot_username = format!("import-{source_slug}-{owner_username}"); + let tool_id = format!("import-{source_slug}"); let conn = pool.write()?; - if let Some(existing) = queries::users::find_bot_by_username(&conn, &bot_username)? { - return Ok(existing.id); - } - let bot = queries::users::create_bot_user(&conn, owner_id, &bot_username, display)?; + let bot = queries::users::ensure_bot(&conn, owner_id, &tool_id, display)?; Ok(bot.id) } diff --git a/src/main.rs b/src/main.rs index 877c3472..952b657a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod links; mod mcp; mod oauth; mod ratelimit; +mod resolve_caller; mod realtime; mod storage; @@ -180,7 +181,13 @@ async fn main() -> Result<(), Box> { } match cli.command { - Command::Init { no_service, here } => { + Command::Init { + no_service, + here, + name, + auth_mode, + password, + } => { // LIF-292: init/service must honor --config; they take the raw // flag (not the pre-loaded cfg) because init may need to CREATE // the file at that path and then reload anchored to it. @@ -190,6 +197,9 @@ async fn main() -> Result<(), Box> { cli.json, no_service, here, + name, + auth_mode, + password, ) .await; } @@ -829,21 +839,19 @@ async fn main() -> Result<(), Box> { // when the instance says it's publicly reachable; shout otherwise // (the default bind is 0.0.0.0 — the whole LAN can reach it). if !cfg.auth.required { - if let Some(url) = cfg.server.public_url.as_deref() - && !config::is_localhost_url(url) - { + if !config::is_localhost_host(&cfg.server.host) { return Err(format!( - "refusing to start: [auth] required = false while server.public_url \ - ({url}) is not localhost — an instance without authentication must \ - never be publicly reachable. Re-enable auth or remove public_url." + "refusing to start: [auth] required = false (login-free mode) while \ + [server] host ({}) is not loopback — anyone who can reach that bind \ + can administer the instance. Re-enable auth or bind to 127.0.0.1.", + cfg.server.host ) .into()); } warn!( host = %cfg.server.host, - "AUTH IS DISABLED ([auth] required = false): every credential-less request \ - gets admin-equivalent access. Anyone who can reach this address owns the \ - instance — keep it loopback-only or firewalled." + "{} ([auth] required = false)", + config::login_free_caution() ); } @@ -882,11 +890,22 @@ async fn main() -> Result<(), Box> { let manager = auth::create_key_manager().map_err(|e| format!("key manager init failed: {e}"))?; - // Auto-generate a key if none exist - if !auth::has_any_keys(&pool) { + // Auto-generate a key if none exist and no human operator exists + // yet (LIFIC-9: once a human exists we stop auto-minting the + // unbound "default" key — keys are minted on demand). The three + // branches are mutually exclusive by construction: + // 1. empty bootstrap (no human, no keys) → mint the default key + // 2. human present, still no keys → passwordless mode + // 3. keys exist → plain count + if auth::should_mint_initial_key(&pool) { let key = auth::create_api_key(&pool, &manager, "default")?; info!("no API keys found, auto-generated initial key"); print_initial_key(&key); + } else if !auth::has_any_keys(&pool) { + // A human operator exists (should_mint was false for lack of + // keys alone) but no key has been created yet: keys are minted + // on demand via `lific key create`. + info!("human operator present — passwordless mode; mint keys on demand with `lific key create`"); } else { let count = auth::list_api_keys(&pool)? .iter() @@ -995,15 +1014,6 @@ async fn main() -> Result<(), Box> { .cloned() .flatten(); - // LIF-261: the auth middleware marks an operator-trusted - // unbound API key with the OperatorCredential extension. - // Forward it so MCP tools' authz gates treat it as - // admin-equivalent in enforced mode. - let is_operator = request - .extensions() - .get::() - .is_some(); - let issue_links = links::IssueLinkContext::for_http_request( mcp_public_url.as_deref(), request @@ -1013,7 +1023,7 @@ async fn main() -> Result<(), Box> { &mcp_allowed_hosts_for_links, ); - mcp::with_request_context(auth_user, is_operator, issue_links, || async { + mcp::with_request_context(auth_user, issue_links, || async { mcp_service.handle(request).await.into_response() }) .await @@ -1076,9 +1086,16 @@ async fn main() -> Result<(), Box> { display_name: u.display_name, is_admin: u.is_admin, }), - None => db::queries::users::first_admin(&conn) - .ok() - .flatten(), + // LIFIC-8: the "no credential → first admin" + // fallback is consolidated in `resolve_caller`. + None => resolve_caller::resolve_caller_conn( + &conn, + None, + actor::Transport::Mcp, + ) + .ok() + .flatten() + .map(|i| i.user), }, Err(_) => None, } @@ -1316,11 +1333,44 @@ async fn main() -> Result<(), Box> { let pool = db::open(&cfg.database.path)?; info!(path = %cfg.database.path.display(), "database ready"); + // LIFIC-18: a stdio agent carries its identity in LIFIC_TOKEN. Read + // it at startup, validate it, and resolve the caller as that agent + // for the whole session. A missing/invalid token runs as the + // operator with a stderr warning — never a hard error (MCP stdio + // has no transport auth; the launch boundary is the trust). + let manager = auth::create_key_manager()?; + let token_user = match auth::resolve_stdio_token(&pool, &manager) { + Ok(Some(user)) => Some(user), + Ok(None) => { + // Absent or valid-but-unbound (e.g. a fresh-install + // unassigned key): run as the operator, with a warning. + eprintln!( + "LIFIC_TOKEN not set or unbound — this session runs as the operator, \ + not a connected agent.\n\ + Run `lific connect` to bind this session to an agent identity." + ); + None + } + Err(e) => { + eprintln!( + "LIFIC_TOKEN present but invalid ({e}) — this session runs as the \ + operator, not a connected agent." + ); + None + } + }; + let server = mcp::LificMcp::new(pool); + // LIFIC-18: bind the resolved agent (or operator) as this stdio + // session's identity for the whole process lifetime. + mcp::set_stdio_user(token_user.clone()); let transport = rmcp::transport::io::stdio(); info!("lific MCP server started (stdio)"); let handle = server.serve(transport).await?; + if let Some(u) = &token_user { + info!(user = %u.username, "stdio session bound to agent"); + } handle.waiting().await?; } @@ -1420,12 +1470,91 @@ fn resolve_init_target( } } +/// Load the config file `init` operates on, applying the optional `--db` +/// override on top. Shared by the initial load and the post-auth-mode reload +/// (LIFIC-25), so the override logic lives in exactly one place. +fn load_config_for_init( + config_path: &std::path::Path, + db_flag: Option<&std::path::Path>, +) -> Config { + let mut cfg = Config::load(Some(config_path)); + if let Some(db) = db_flag { + cfg.database.path = db.to_path_buf(); + } + cfg +} + +/// Resolve the auth mode the operator chose at `init` (LIFIC-25). Honors an +/// explicit `--auth-mode` flag (non-interactive); otherwise, on a TTY, shows +/// the interactive menu. Refuses (rather than hangs) off a TTY, matching +/// `prompt_text`/`confirm`, and names the bypass flag. +fn resolve_auth_mode(flag: &Option) -> Result> { + if let Some(value) = flag { + return config::AuthMode::parse(value).ok_or_else(|| { + format!("invalid --auth-mode '{value}': expected login-free or passwords").into() + }); + } + if !cli::term::stdin_is_tty() { + return Err( + "auth-mode selection requires a terminal; re-run with --auth-mode login-free|passwords" + .into(), + ); + } + let mut prompt = cliclack::Select::new("How do you want to sign in?"); + prompt = prompt + .item( + config::AuthMode::LoginFree, + "Login-free", + "no password; your browser signs you in; binds to 127.0.0.1", + ) + .item( + config::AuthMode::Passwords, + "Passwords", + "set a password and sign in on the web", + ); + let mode = prompt.interact().map_err(|e| -> Box { + if e.kind() == std::io::ErrorKind::Interrupted { + "cancelled".into() + } else { + format!("auth-mode selection failed: {e}").into() + } + })?; + if mode == config::AuthMode::LoginFree + && !cli::term::confirm( + &format!("{}\n\nProceed?", config::login_free_caution()), + "--auth-mode login-free", + )? + { + return Err("cancelled".into()); + } + Ok(mode) +} + +/// Prompt for the operator's password in `--auth-mode passwords`. Masked on a +/// TTY; read-a-line when piped (so scripts can supply it), matching the `user +/// create` flow. +fn prompt_password_for_auth_mode() -> Result> { + if cli::term::stdin_is_tty() { + Ok(cliclack::password("Operator password").interact()?) + } else { + let mut buf = String::new(); + std::io::stdin().read_line(&mut buf)?; + Ok(buf.trim().to_string()) + } +} + +// clap can't express the --config conflict, and init threads many small flags; +// the repo tolerates this for command handlers (see cli/import.rs). +#[allow(clippy::too_many_arguments)] async fn cmd_init( config_flag: Option<&std::path::Path>, db_flag: Option<&std::path::Path>, json_flag: bool, no_service: bool, here: bool, + name: Option, + auth_mode_flag: Option, + password_flag: Option, ) -> Result<(), Box> { use cli::ui; // clap can't express this conflict: --config is a global arg on the @@ -1466,12 +1595,9 @@ async fn cmd_init( // database.path anchors to the config's own directory — the same // resolution the installed service (WorkingDirectory = that directory) // applies at runtime. The pre-dispatch Config::load can't have done - // this when the file didn't exist yet. - let mut cfg = Config::load(Some(&config_path)); - if let Some(db) = db_flag { - cfg.database.path = db.to_path_buf(); - } - let cfg = &cfg; + // this when the file didn't exist yet. Applied again after the auth-mode + // edit rewrites the file (LIFIC-25). + let mut cfg = load_config_for_init(&config_path, db_flag); // Create + migrate the database and seed instance settings now, while the // instance has zero users — this is the moment the authz-enforced default @@ -1488,22 +1614,74 @@ async fn cmd_init( db::queries::settings::ensure(&conn, cfg.auth.allow_signup)?; } + // LIFIC-25: on a fresh install (no human operator yet) the operator picks + // an auth mode — login-free or passwords. Resolve it (flag, or an + // interactive TTY menu), persist the choice to the config file + database, + // and create the first admin in that mode. An existing instance with users + // skips all of this entirely. + let created_admin = if !auth::has_human_operator(&pool) { + let mode = resolve_auth_mode(&auth_mode_flag)?; + + // Persist the choice into the config file, editing it in place (the + // change set `[auth] required` and `[server] host`; every other section + // and setting survives). Reload cfg so downstream (local_url, JSON, + // service plan) reflects required/host. + let existing = std::fs::read_to_string(&config_path).unwrap_or_default(); + let new_toml = Config::apply_auth_mode(&existing, mode.required(), mode.host())?; + std::fs::write(&config_path, new_toml)?; + cfg = load_config_for_init(&config_path, db_flag); + + let op_name = match name { + Some(n) => n, + None => cli::term::prompt_text("What's your name?", "--name") + .map_err(|e| -> Box { e.into() })?, + }; + + // Write web_auto_login to the DB beside the admin (it lives in the + // database, not the config). On for login-free so the browser signs the + // operator in; off for password mode. + let conn = pool.write()?; + db::queries::settings::update( + &conn, + db::queries::settings::InstanceSettingsPatch { + web_auto_login: Some(mode.web_auto_login()), + ..Default::default() + }, + )?; + + let admin = if mode.passwordless() { + db::queries::users::create_passwordless_admin(&conn, &op_name)? + } else { + let pw = match &password_flag { + Some(p) => p.clone(), + None => prompt_password_for_auth_mode()?, + }; + db::queries::users::create_first_admin_with_password(&conn, &op_name, &pw)? + }; + info!(operator = %admin.username, mode = mode.as_str(), "created first human admin"); + Some(admin) + } else { + None + }; + // Mint the initial API key HERE, in the operator's terminal. Once the // server runs as a background service, its stdout goes to the journal - // where nobody would see a printed key. - let new_key = if auth::has_any_keys(&pool) { - None - } else { + // where nobody would see a printed key. LIFIC-9: once a human admin exists + // we stop auto-minting the unbound "default" key — the operator is a real + // user now, and keys are minted on demand via `lific key create`. + let new_key = if auth::should_mint_initial_key(&pool) { let manager = auth::create_key_manager().map_err(|e| format!("key manager init failed: {e}"))?; Some(auth::create_api_key(&pool, &manager, "default")?) + } else { + None }; // Release the CLI's DB handles before the service process opens the file. drop(pool); // Background service: the README's 60-second setup has to end with a // server that is still alive tomorrow, not a process tied to a terminal. - let url = local_url(cfg); + let url = local_url(&cfg); let mut service_report = None; let mut service_error = None; let mut healthy = false; @@ -1566,6 +1744,12 @@ async fn cmd_init( "config": { "path": config_path.display().to_string(), "created": created_config }, "database": cfg.database.path.display().to_string(), "key": new_key, + "admin": created_admin.as_ref().map(|a| serde_json::json!({ + "id": a.id, + "username": a.username, + "display_name": a.display_name, + "is_admin": a.is_admin, + })), "url": url, "service": { "requested": !no_service, @@ -1585,6 +1769,13 @@ async fn cmd_init( } ui::step(format!("Database ready {}", ui::dim(cfg.database.path.display()))); + if let Some(ref admin) = created_admin { + ui::step(format!( + "First operator {} created — passwordless mode is on", + ui::command(&admin.display_name) + )); + } + if let Some(ref key) = new_key { ui::note( "Initial API key — save it now, it will not be shown again", @@ -1856,7 +2047,7 @@ fn build_authless_mcp_router( .and_then(|value| value.to_str().ok()), &allowed_hosts_for_links, ); - mcp::with_request_context(user, false, issue_links, || async { + mcp::with_request_context(user, issue_links, || async { service.handle(request).await.into_response() }) .await @@ -1924,7 +2115,8 @@ async fn shutdown_signal(pool: db::DbPool) { #[cfg(test)] mod init_target_tests { - use super::resolve_init_target; + use super::{auth, cmd_init, resolve_init_target, Config}; + use crate::db; use std::path::{Path, PathBuf}; fn os_default() -> Option<(PathBuf, PathBuf)> { @@ -1983,17 +2175,179 @@ mod init_target_tests { // filesystem access, so calling it here is side-effect free. #[tokio::test] async fn init_rejects_here_with_config() { - let err = super::cmd_init( + let err = cmd_init( Some(Path::new("/tmp/nonexistent/lific.toml")), None, true, // json true, // no_service true, // here + Some("test".into()), + None, // auth_mode + None, // password ) .await .unwrap_err(); assert!(err.to_string().contains("--here conflicts with --config")); } + + // A temp dir that self-destructs, so cmd_init's filesystem writes stay out + // of the repo tree and don't collide across tests. + struct TempDir(std::path::PathBuf); + impl TempDir { + fn new() -> Self { + let dir = std::env::temp_dir().join(format!( + "lific-init-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + TempDir(dir) + } + fn path(&self) -> &std::path::Path { + &self.0 + } + } + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// Run `lific init --config /lific.toml --no-service` for the operator + /// `name` and assert on the DB state it wrote (stdout isn't a TTY under the + /// test harness, so we can't capture cmd_init's printed JSON — instead we + /// re-open the database and read back the shared facts). + async fn run_init( + dir: &TempDir, + name: Option<&str>, + auth_mode: Option<&str>, + password: Option<&str>, + ) -> Result> { + let config_path = dir.path().join("lific.toml"); + cmd_init( + Some(&config_path), + None, + true, // json + true, // no_service + false, // here + name.map(str::to_string), + auth_mode.map(str::to_string), + password.map(str::to_string), + ) + .await?; + let cfg = Config::load(Some(&config_path)); + let pool = db::open(&cfg.database.path)?; + let conn = pool.read().unwrap(); + let admin = crate::db::queries::users::first_admin(&conn)?; + let settings = crate::db::queries::settings::get(&conn).ok(); + Ok(serde_json::json!({ + "admin": admin.as_ref().map(|a| a.username.clone()), + "admin_display": admin.as_ref().map(|a| a.display_name.clone()), + "keys": auth::has_any_keys(&pool), + "host": cfg.server.host, + "required": cfg.auth.required, + "web_auto_login": settings.map(|s| s.web_auto_login), + })) + } + + // LIFIC-9: a fresh install (no humans) creates the first passwordless admin + // when given `--name` non-interactively (login-free mode). + #[tokio::test] + async fn init_fresh_install_creates_first_admin_with_name() { + let dir = TempDir::new(); + let out = run_init(&dir, Some("Blake Alston"), Some("login-free"), None) + .await + .unwrap(); + assert_eq!(out["admin"], serde_json::json!("blake-alston")); + } + + // LIFIC-9: once a human admin exists, init skips minting the unbound + // "default" key (passwordless mode) — no key is auto-generated. + #[tokio::test] + async fn init_fresh_install_skips_default_key_when_admin_created() { + let dir = TempDir::new(); + let out = run_init(&dir, Some("Blake"), Some("login-free"), None) + .await + .unwrap(); + assert_eq!(out["admin"], serde_json::json!("blake")); + assert_eq!( + out["keys"], serde_json::json!(false), + "a human operator exists, so no unbound default key is minted" + ); + } + + // LIFIC-9: re-running init on an existing instance (admins already exist) + // skips creation — idempotent, existing setup untouched. + #[tokio::test] + async fn init_existing_install_skips_admin_creation() { + let dir = TempDir::new(); + let first = run_init(&dir, Some("Blake"), Some("login-free"), None) + .await + .unwrap(); + assert_eq!(first["admin"], serde_json::json!("blake")); + + // Second run with a different name must NOT create a second admin. + let second = run_init(&dir, Some("Someone Else"), Some("passwords"), Some("hunter22!")) + .await + .unwrap(); + assert_eq!( + second["admin"], serde_json::json!("blake"), + "existing instance keeps its first admin" + ); + } + + // LIFIC-25: login-free mode writes required=false, host=127.0.0.1, + // web_auto_login=true, and a passwordless admin. + #[tokio::test] + async fn init_login_free_wires_config_db_and_passwordless_admin() { + let dir = TempDir::new(); + let out = run_init(&dir, Some("Blake"), Some("login-free"), None) + .await + .unwrap(); + assert_eq!(out["admin_display"], serde_json::json!("Blake")); + assert_eq!(out["required"], serde_json::json!(false)); + assert_eq!(out["host"], serde_json::json!("127.0.0.1")); + assert_eq!(out["web_auto_login"], serde_json::json!(true)); + } + + // LIFIC-25: password mode writes required=true, leaves host unchanged, + // web_auto_login=false, and creates an admin with the chosen password. + #[tokio::test] + async fn init_passwords_wires_config_db_and_passworded_admin() { + let dir = TempDir::new(); + let out = run_init(&dir, Some("Blake"), Some("passwords"), Some("hunter22!")) + .await + .unwrap(); + assert_eq!(out["required"], serde_json::json!(true)); + // host is left at its default (0.0.0.0) — password mode never binds loopback. + assert_eq!(out["host"], serde_json::json!("0.0.0.0")); + assert_eq!(out["web_auto_login"], serde_json::json!(false)); + // Passworded admin can sign in. + assert_eq!(out["admin"], serde_json::json!("blake")); + } + + // LIFIC-25: an invalid --auth-mode is rejected. + #[tokio::test] + async fn init_rejects_invalid_auth_mode() { + let dir = TempDir::new(); + let config_path = dir.path().join("lific.toml"); + let err = cmd_init( + Some(&config_path), + None, + true, // json + true, // no_service + false, + Some("Blake".to_string()), + Some("bogus".to_string()), + None, + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("invalid --auth-mode")); + } } #[cfg(test)] diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 88632260..ab8d560a 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -28,15 +28,6 @@ static MCP_HANDLER_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new( /// Uses unwrap_or_else to recover from poison (e.g. if a handler panics). static MCP_REQUEST_USER: Mutex> = Mutex::new(None); -/// LIF-261: per-request "the credential is an operator-trusted unbound API -/// key" flag, mirroring [`MCP_REQUEST_USER`]. A task-local can't carry this on -/// the MCP path because rmcp spawns internal tasks that drop it, so it lives in -/// a global guarded by the same serialization lock. Read by `authz` (via -/// [`current_is_operator`]) to treat an unbound API key as admin-equivalent in -/// enforced mode without granting that power to a legacy unbound OAuth token -/// (which also resolves to `AuthUser = None`). -static MCP_REQUEST_OPERATOR: Mutex = Mutex::new(false); - /// Per-request external origin used for structured resource links. /// Protected by [`MCP_HANDLER_LOCK`] for the same reason as the identity state. static MCP_REQUEST_ISSUE_LINKS: Mutex>> = Mutex::new(None); @@ -64,22 +55,7 @@ where F: FnOnce() -> Fut, Fut: std::future::Future, { - with_request_context(user, false, None, f).await -} - -/// LIF-261: like [`with_request_user`] but also records whether the request's -/// credential is an operator-trusted unbound API key. The `/mcp` route passes -/// `true` only when the auth middleware resolved an unbound API key (never for -/// OAuth/session tokens), so `authz` can treat it as admin-equivalent in -/// enforced mode. `with_request_user` keeps the old signature (operator = -/// false) for every non-unbound-key caller. -#[cfg_attr(not(test), allow(dead_code))] -pub async fn with_request_identity(user: Option, is_operator: bool, f: F) -> R -where - F: FnOnce() -> Fut, - Fut: std::future::Future, -{ - with_request_context(user, is_operator, None, f).await + with_request_context(user, None, f).await } /// Run an MCP request with its authenticated identity and optional external @@ -87,7 +63,6 @@ where /// HTTP callers pass the validated browser-facing base URL. pub async fn with_request_context( user: Option, - is_operator: bool, issue_links: Option, f: F, ) -> R @@ -106,30 +81,38 @@ where *MCP_REQUEST_USER .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) = user; - *MCP_REQUEST_OPERATOR - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = is_operator; *MCP_REQUEST_ISSUE_LINKS .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) = issue_links; + // Panic-safe cleanup: clear the globals on scope exit (including if `f` + // panics), before `_guard` releases MCP_HANDLER_LOCK (reverse declaration + // order). Without this, a panicking request would leave a stale user in the + // process-wide global for the next (concurrent) test to read. + let _clear = RequestGlobalGuard; #[cfg(test)] let result = TEST_REQUEST_ISSUE_LINKS .scope(test_issue_links, crate::actor::scope(actor, f())) .await; #[cfg(not(test))] let result = crate::actor::scope(actor, f()).await; - *MCP_REQUEST_USER - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; - *MCP_REQUEST_OPERATOR - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = false; - *MCP_REQUEST_ISSUE_LINKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; result } +/// Drops the per-request globals on scope exit (panic-safe). Declared after the +/// globals are set in [`with_request_context`], so it runs before the handler +/// lock is released. +struct RequestGlobalGuard; +impl Drop for RequestGlobalGuard { + fn drop(&mut self) { + *MCP_REQUEST_USER + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + *MCP_REQUEST_ISSUE_LINKS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + } +} + /// Get the authenticated user for the current MCP request, if any. pub(crate) fn current_auth_user() -> Option { MCP_REQUEST_USER @@ -138,12 +121,31 @@ pub(crate) fn current_auth_user() -> Option { .clone() } -/// LIF-261: whether the current MCP request's credential is an operator-trusted -/// unbound API key. Read by `authz::operator_context`. -pub(crate) fn current_is_operator() -> bool { - *MCP_REQUEST_OPERATOR +/// LIFIC-18: install the session-level identity for a stdio MCP server. +/// +/// A stdio session is one long-lived, serialized process that is never wrapped +/// in [`with_request_context`] (there is no HTTP request to carry the user), so +/// installing the resolved agent here makes every tool call resolve as that +/// agent via [`current_auth_user`] until the process exits. The operator +/// fallback (a missing/unbound `LIFIC_TOKEN`) is the caller passing `None`, +/// which keeps the existing credential-less resolution — also the operator. +pub(crate) fn set_stdio_user(user: Option) { + *MCP_REQUEST_USER .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) + .unwrap_or_else(|poisoned| poisoned.into_inner()) = user; +} + +/// LIFIC-11: the resolved identity for the current MCP request. MCP now +/// resolves the caller exactly as REST does — via [`crate::resolve_caller::resolve_caller`] — +/// so a credential-less request (unbound API key, legacy OAuth token, or a +/// stdio session with no bound user) falls back to the first admin, and the +/// gates read `identity.user.is_admin`. The separate operator flag is gone: +/// every credential that authenticates is trusted as the operator, matching +/// REST one-for-one (no transport-specific divergence). +pub(crate) fn current_identity(db: &crate::db::DbPool) -> Option { + crate::resolve_caller::resolve_caller(db, current_auth_user(), crate::actor::Transport::Mcp) + .ok() + .flatten() } /// Get the validated external origin for structured resource links, if this MCP @@ -424,44 +426,10 @@ mod tests { assert!(current_auth_user().is_none()); } - // ── LIF-261: operator flag on the MCP request identity global ────────── - // - // The `/mcp` route calls `with_request_identity(user, is_operator, ..)`; - // MCP tools' authz gates read the operator flag via - // `current_is_operator()`. These prove the global is set/read/cleared and - // that `with_request_user` keeps the non-operator default. - - #[tokio::test] - async fn with_request_identity_exposes_and_clears_operator_flag() { - // Default (no request) is false. - assert!(!current_is_operator()); - - let seen = with_request_identity(None, true, || async { current_is_operator() }).await; - assert!( - seen, - "operator flag must be visible inside the request scope" - ); - - // Cleared after the request completes. - assert!( - !current_is_operator(), - "operator flag must be cleared after the request" - ); - } - - #[tokio::test] - async fn with_request_user_defaults_operator_false() { - let seen = with_request_user(None, || async { current_is_operator() }).await; - assert!( - !seen, - "with_request_user (non-unbound-key callers) must never set the operator flag" - ); - } - #[tokio::test] async fn with_request_context_scopes_issue_link_origin() { let context = IssueLinkContext::parse("https://tracker.example/base"); - let (seen, global_seen) = with_request_context(None, false, context, || async { + let (seen, global_seen) = with_request_context(None, context, || async { let scoped = current_issue_link_context() .expect("request origin should be visible") .issue_markdown("LIF-1") @@ -491,16 +459,35 @@ mod tests { ); } - // End-to-end: an operator-trusted unbound API key aimed at /mcp passes an - // enforced-mode MCP Viewer gate, while a legacy unbound OAuth token does - // not. Mirrors the /mcp route wiring (require_api_key → OperatorCredential - // extension → with_request_identity), then runs a real MCP gate. + // End-to-end: a credential-less MCP request resolves to the first admin + // (via resolve_caller), so it passes an enforced-mode Viewer gate. LIFIC-11 + // unified MCP onto the same resolve_caller path REST uses, so the old + // operator-vs-legacy-OAuth distinction is gone: an unbound API key and a + // legacy unbound OAuth token both authenticate and both resolve to the + // first admin. Mirrors the /mcp route wiring (require_api_key → with_request_user + // → current_identity), then runs a real authz gate. #[tokio::test] - async fn mcp_operator_key_passes_gate_but_legacy_oauth_does_not() { + async fn unbound_credentials_resolve_to_first_admin_and_pass_mcp_gate() { use axum::extract::State; use axum::response::IntoResponse; let pool = crate::db::open_memory().expect("test db"); + // resolve_caller needs a first_admin to resolve credential-less requests to + { + let conn = pool.write().unwrap(); + crate::db::queries::users::create_user( + &conn, + &crate::db::models::CreateUser { + username: "admin".into(), + email: "admin@test.local".into(), + password: "adminpass123".into(), + display_name: None, + is_admin: true, + is_bot: false, + }, + ) + .unwrap(); + } let manager = crate::auth::create_key_manager().unwrap(); let unbound_key = crate::auth::create_api_key(&pool, &manager, "mcp-operator").unwrap(); let project = { @@ -529,23 +516,17 @@ mod tests { let oauth_token = insert_oauth_token(&pool, "mcp-legacy-unbound", None); // Route that mirrors main.rs's /mcp identity plumbing, then runs the - // same authz gate an MCP Viewer-tool would (via the tools.rs - // require_role_mcp path — here inlined as authz::require_role over the - // MCP current_auth_user()). + // same authz gate an MCP Viewer-tool would (authz::require_role over + // the resolved identity). async fn gate( State((pool, project_id)): State<(DbPool, i64)>, axum::Extension(auth_user): axum::Extension>, - request: axum::extract::Request, ) -> axum::response::Response { - let is_operator = request - .extensions() - .get::() - .is_some(); - crate::mcp::with_request_identity(auth_user, is_operator, || async { + crate::mcp::with_request_user(auth_user, || async { let db = std::sync::Arc::new(pool); match crate::authz::require_role( &db, - &crate::mcp::current_auth_user(), + &crate::mcp::current_identity(&db), project_id, crate::db::models::Role::Viewer, ) { @@ -586,12 +567,12 @@ mod tests { assert_eq!( status(unbound_key, app.clone()).await, StatusCode::OK, - "operator-trusted unbound API key must pass the enforced MCP Viewer gate" + "an unbound API key authenticates and resolves to the first admin, passing the enforced MCP Viewer gate" ); assert_eq!( status(oauth_token, app).await, - StatusCode::FORBIDDEN, - "legacy unbound OAuth token must NOT gain operator power on the MCP surface" + StatusCode::OK, + "a legacy unbound OAuth token also authenticates and resolves to the first admin — MCP and REST now share one resolve_caller path" ); } @@ -656,4 +637,79 @@ mod tests { "convention addition grew to {addition} chars; keep it tight" ); } + + // ── LIFIC-18: stdio session identity (set_stdio_user seam) ───────────── + // + // The `lific mcp` entrypoint installs the session identity once at startup + // via `set_stdio_user` (see main.rs), and every tool call resolves through + // `current_identity`. This is seam two of the spec: with a valid bound + // token the agent resolves as itself; with none, the operator (first + // admin) fallback applies. + + fn seed_user(pool: &crate::db::DbPool, username: &str, admin: bool) -> crate::db::models::AuthUser { + let conn = pool.write().expect("write conn"); + let u = crate::db::queries::users::create_user( + &conn, + &crate::db::models::CreateUser { + username: username.into(), + email: format!("{username}@local.test"), + password: "somepass123".into(), + display_name: None, + is_admin: admin, + is_bot: false, + }, + ) + .expect("create user"); + crate::db::models::AuthUser { + id: u.id, + username: u.username, + display_name: u.display_name, + is_admin: u.is_admin, + } + } + + #[test] + fn stdio_session_with_agent_identity_resolves_as_that_agent() { + // Serialize against the whole MCP suite: `set_stdio_user` mutates the + // process-wide MCP_REQUEST_USER global, which concurrent tool tests + // also read. Holding the shared test guard prevents a cross-test race. + let _sguard = crate::mcp::tools::acquire_test_guard(); + // A first admin exists as the operator fallback, but the bound session + // must resolve to the agent, NOT the admin. + let pool = crate::db::open_memory().expect("test db"); + let admin = seed_user(&pool, "admin", true); + let agent = seed_user(&pool, "opencode-solo", false); + + // Mirrors main.rs: LIFIC_TOKEN resolved to `agent`, installed for the + // whole session. + set_stdio_user(Some(agent.clone())); + + let identity = current_identity(&pool).expect("a bound stdio session resolves"); + assert_eq!( + identity.user, agent, + "agent session must resolve as the agent, not the operator" + ); + assert_ne!(identity.user.id, admin.id); + assert_eq!(identity.transport, crate::actor::Transport::Mcp); + } + + #[test] + fn stdio_session_without_identity_falls_back_to_operator() { + // Serialize against the whole MCP suite for the same process-global + // reason as above (set_stdio_user writes MCP_REQUEST_USER). + let _sguard = crate::mcp::tools::acquire_test_guard(); + // No LIFIC_TOKEN / unbound → `set_stdio_user(None)` → the operator + // (first admin) fallback, the same pre-LIFIC-18 behavior. + let pool = crate::db::open_memory().expect("test db"); + let admin = seed_user(&pool, "operator", true); + + set_stdio_user(None); + let identity = current_identity(&pool).expect("operator fallback resolves"); + assert_eq!( + identity.user.id, admin.id, + "no-token stdio session must resolve to the first admin" + ); + assert!(identity.user.is_admin); + assert_eq!(identity.transport, crate::actor::Transport::Mcp); + } } diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 509890fa..d9f2c662 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -1177,68 +1177,39 @@ impl Display for ActivityLine<'_> { // ── LIF-198: MCP authorization gates ──────────────────────────── // -// Same enforcement primitives as REST (LIF-197): `crate::authz`. But unlike -// the REST wrappers in `api/mod.rs` / `api/pages.rs` / `api/resources.rs` -// (which forward straight into `authz::require_structure_role` / -// `require_project_delete_role` / `require_role(.., Lead)` and rely on -// *those* functions' legacy branches to reproduce REST's pre-existing -// behavior), MCP never had ANY project-scoped gate before this issue — every -// tool was wide open. Those legacy branches reproduce specifically REST's -// history (e.g. structure endpoints were Lead-gated pre-LIF-194, project -// delete was admin-only), which is a *regression* if borrowed verbatim by -// MCP: it would newly deny calls that MCP always allowed. -// -// So every MCP gate below checks `authz_enforced` itself first and -// short-circuits to an unconditional allow while the flag is off — -// reproducing MCP's actual historical behavior (fully open), not REST's. -// Once `authz_enforced` is on, each gate delegates to the exact same -// `crate::authz` primitive REST uses, so enforced-mode semantics are -// identical across both transports. `mcp_gate` centralizes that flag check; -// `LificError` denials translate to the `String` error type -// `self.read`/`self.write` already use, so a denial renders as the same +// LIFIC-11: MCP gates now call the exact same `crate::authz` primitives REST +// does — one seam, one behavior, no transport-specific divergence. The old +// `mcp_gate` short-circuit that kept MCP wide-open in legacy mode is gone: +// `authz::*` carry their own legacy-mode branches, so MCP inherits identical +// semantics to REST. The `LificError` denials translate to the `String` error +// type `self.read`/`self.write` use, rendering as the same // `Error: Forbidden: ` shape every other MCP error uses. -fn mcp_gate( - db: &Arc, - check: impl FnOnce() -> Result<(), crate::error::LificError>, -) -> Result<(), String> { - match crate::authz::authz_enforced(db) { - Ok(false) => Ok(()), - Ok(true) => check().map_err(|e| e.to_string()), - Err(e) => Err(e.to_string()), - } -} /// Require the caller hold at least `min` role on `project_id`. fn require_role_mcp(db: &Arc, project_id: i64, min: models::Role) -> Result<(), String> { - mcp_gate(db, || { - crate::authz::require_role(db, &super::current_auth_user(), project_id, min) - }) + crate::authz::require_role(db, &super::current_identity(db), project_id, min) + .map_err(|e| e.to_string()) } /// Gate for module/label/folder ("structure") mutations — Maintainer once -/// enforcement is on; a no-op (MCP's historical behavior) in legacy mode. +/// enforcement is on, Lead in legacy mode (matches REST). fn require_structure_role_mcp(db: &Arc, project_id: i64) -> Result<(), String> { - mcp_gate(db, || { - crate::authz::require_structure_role(db, &super::current_auth_user(), project_id) - }) + crate::authz::require_structure_role(db, &super::current_identity(db), project_id) + .map_err(|e| e.to_string()) } /// Gate for `delete(resource_type="project")` — Lead once enforcement is on -/// (design decision #6); a no-op (MCP's historical behavior) in legacy mode. +/// (design decision #6), admin-only in legacy mode (matches REST). fn require_project_delete_role_mcp(db: &Arc, project_id: i64) -> Result<(), String> { - mcp_gate(db, || { - crate::authz::require_project_delete_role(db, &super::current_auth_user(), project_id) - }) + crate::authz::require_project_delete_role(db, &super::current_identity(db), project_id) + .map_err(|e| e.to_string()) } /// Gate for workspace-level (project-less) pages/comments — admin-only once -/// enforcement is on, a no-op in legacy mode either way (matches -/// `authz::require_workspace_admin`'s own legacy branch, so `mcp_gate`'s -/// short-circuit here is redundant but harmless). +/// enforcement is on, a no-op in legacy mode (matches REST). fn require_workspace_admin_mcp(db: &Arc) -> Result<(), String> { - mcp_gate(db, || { - crate::authz::require_workspace_admin(db, &super::current_auth_user()) - }) + crate::authz::require_workspace_admin(db, &super::current_identity(db)) + .map_err(|e| e.to_string()) } /// Gate for a page/comment target whose `project_id` may be `None` @@ -1276,7 +1247,7 @@ fn filter_visible( fn visible_project_ids_mcp( db: &Arc, ) -> Result>, String> { - crate::authz::visible_project_ids(db, &super::current_auth_user()).map_err(|e| e.to_string()) + crate::authz::visible_project_ids(db, &super::current_identity(db)).map_err(|e| e.to_string()) } impl LificMcp { @@ -1318,14 +1289,20 @@ impl LificMcp { /// the task-local, else fall back to the first admin for stdio/local /// sessions. Returns `(user_id, is_admin)` for the author-or-admin /// ownership check. + /// + /// LIFIC-8: the fallback now routes through `resolve_caller`, the single + /// place that decides "no credential → first admin" — replacing the + /// direct `first_admin` read. fn resolve_comment_actor(&self) -> Result<(i64, bool), String> { - match super::current_auth_user() { - Some(u) => Ok((u.id, u.is_admin)), - None => match self.read(queries::users::first_admin)? { - Some(admin) => Ok((admin.id, admin.is_admin)), - None => Err("no admin user exists to attribute comment edits to.".into()), - }, - } + let identity = self.read(|conn| { + crate::resolve_caller::resolve_caller_conn( + conn, + super::current_auth_user(), + crate::actor::Transport::Mcp, + ) + })? + .ok_or_else(|| "no admin user exists to attribute comment edits to.".to_string())?; + Ok((identity.user.id, identity.user.is_admin)) } /// LIF-198: if `step_id` has a linked issue, require `min` role on that @@ -3553,15 +3530,21 @@ impl LificMcp { // Resolve the authenticated user from the task-local set by the HTTP handler. // For stdio/local MCP sessions (no HTTP auth), fall back to the first admin user. - let user_id = match super::current_auth_user() { - Some(u) => u.id, - None => match self.read(queries::users::first_admin) { - Ok(Some(admin)) => admin.id, - Ok(None) => { - return "Error: no admin user exists to attribute comments to.".into(); - } - Err(e) => return format!("Error: {e}"), - }, + // + // LIFIC-8: the fallback routes through `resolve_caller`, consolidating the + // "no credential → first admin" decision that was previously inline here. + let user_id = match self.read(|conn| { + crate::resolve_caller::resolve_caller_conn( + conn, + super::current_auth_user(), + crate::actor::Transport::Mcp, + ) + }) { + Ok(Some(identity)) => identity.user.id, + Ok(None) => { + return "Error: no admin user exists to attribute comments to.".into(); + } + Err(e) => return format!("Error: {e}"), }; // LIF-263: resolve the parent's project + the enforcement flag up @@ -4268,24 +4251,74 @@ fn build_create_step( }) } +// LIFIC-11: process-wide serialization lock for MCP tests. `MCP_REQUEST_USER` +// is a static shared across every concurrently-running test; before `mcp_gate`'s +// legacy short-circuit was removed, gated mutations never read it, so the +// sharing was harmless. Now they do (gates resolve the caller via +// `resolve_caller`), so a direct-call test reading `None` can race a concurrent +// `with_request_user`/`seed_user` test writing some other user. Holding this +// lock for the whole test serializes the MCP suite and removes the race. It's +// deliberately a *different* lock from `MCP_HANDLER_LOCK` so +// `seed_user`/`with_request_context` (which acquire that one) don't deadlock. +// Wrapped in a newtype so clippy's `await_holding_lock` lint (which would +// reject a raw `MutexGuard` held across `.await` in `#[tokio::test]`) leaves it +// alone — safe here because each test owns its runtime/thread and tests never +// depend on each other. +// `pub(crate)` (cfg-test only) so the sibling `mcp::tests` module in mod.rs can +// hold the same lock when its own tests mutate that global (LIFIC-18 review). +#[cfg(test)] +static TEST_MCP_SERIALIZATION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(test)] +pub(crate) struct McpTestGuard(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>); + +#[cfg(test)] +pub(crate) fn acquire_test_guard() -> McpTestGuard { + McpTestGuard(TEST_MCP_SERIALIZATION_LOCK.lock().unwrap()) +} + #[cfg(test)] mod tests { use super::*; use rmcp::handler::server::wrapper::Parameters; - fn mcp() -> LificMcp { + /// LIFIC-11: a fresh install has a first admin (LIFIC-9), and MCP gates now + /// resolve the caller via `resolve_caller` — a credential-less request falls + /// back to that admin. Seed one so the structure/project mutations these + /// tests perform (Lead/admin-gated in legacy mode, like REST) resolve to an + /// admin identity instead of default-denying. + fn seed_first_admin(db: &crate::db::DbPool) { + let conn = db.write().unwrap(); + crate::db::queries::users::create_user( + &conn, + &crate::db::models::CreateUser { + username: "admin".into(), + email: "admin@test.local".into(), + password: "adminpass123".into(), + display_name: None, + is_admin: true, + is_bot: false, + }, + ) + .expect("seed first admin"); + } + + fn mcp() -> (LificMcp, McpTestGuard) { let db = crate::db::open_memory().expect("test db"); - LificMcp::new(db) + seed_first_admin(&db); + (LificMcp::new(db), acquire_test_guard()) } fn mcp_with_realtime() -> ( LificMcp, tokio::sync::broadcast::Receiver, + McpTestGuard, ) { let db = crate::db::open_memory().expect("test db"); + seed_first_admin(&db); let realtime = crate::realtime::RealtimeHub::new(); let rx = realtime.subscribe(); - (LificMcp::with_realtime(db, realtime), rx) + (LificMcp::with_realtime(db, realtime), rx, acquire_test_guard()) } fn drain_realtime(rx: &mut tokio::sync::broadcast::Receiver) { @@ -4338,7 +4371,7 @@ mod tests { #[test] fn canonical_project_identifier_comes_from_project_record() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Canonical", "CAN"); let project_id = project_id_for(&m, "CAN"); @@ -4367,6 +4400,7 @@ mod tests { models::AuthUser, models::AuthUser, i64, + McpTestGuard, ) { let (db, admin, lead, maintainer, viewer, non_member, project_id) = crate::api::test_helpers::setup_membership_test(); @@ -4385,12 +4419,13 @@ mod tests { au(viewer), au(non_member), project_id, + acquire_test_guard(), ) } #[test] fn project_resolver_matches_exact_identifiers_only() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Resolver Project", "RSL"); assert!(resolve_project(&m.db, "RSL").expect("exact identifier should resolve") > 0); @@ -4407,7 +4442,8 @@ mod tests { #[test] fn module_resolver_matches_names_case_insensitively_without_substrings() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Resolver Project", "MOD"); let project_id = resolve_project(&m.db, "MOD").expect("project should resolve"); let created = m.manage_resource(Parameters(ManageResourceInput { @@ -4439,7 +4475,8 @@ mod tests { #[test] fn folder_resolver_matches_names_case_insensitively_without_substrings() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Resolver Project", "FLD"); let project_id = resolve_project(&m.db, "FLD").expect("project should resolve"); let created = m.manage_resource(Parameters(ManageResourceInput { @@ -4473,14 +4510,15 @@ mod tests { #[test] fn manage_create_project() { - let m = mcp(); + let (m, _guard) = mcp(); let result = seed_project(&m, "Alpha", "ALP"); assert_eq!(result, "ALP"); } #[test] fn manage_update_project() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Old", "UPD"); let result = m.manage_resource(Parameters(ManageResourceInput { resource_type: "project".into(), @@ -4499,7 +4537,8 @@ mod tests { #[test] fn manage_update_project_description_persists() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Project", "DSC"); let project_id = project_id_for(&m, "DSC"); @@ -4525,7 +4564,8 @@ mod tests { #[test] fn manage_update_project_identifier_persists() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Project", "OLD"); let project_id = project_id_for(&m, "OLD"); @@ -4552,7 +4592,8 @@ mod tests { #[test] fn manage_update_project_with_current_name_requires_project_identifier() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Original", "ORG"); let project_id = project_id_for(&m, "ORG"); @@ -4583,7 +4624,8 @@ mod tests { #[test] fn manage_create_module() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Test", "MOD"); let result = m.manage_resource(Parameters(ManageResourceInput { resource_type: "module".into(), @@ -4602,7 +4644,8 @@ mod tests { #[test] fn manage_create_label() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Test", "LBL"); let result = m.manage_resource(Parameters(ManageResourceInput { resource_type: "label".into(), @@ -4622,7 +4665,8 @@ mod tests { #[test] fn manage_create_folder() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Test", "FLD"); let result = m.manage_resource(Parameters(ManageResourceInput { resource_type: "folder".into(), @@ -4641,7 +4685,7 @@ mod tests { #[test] fn manage_missing_name_errors() { - let m = mcp(); + let (m, _guard) = mcp(); let result = m.manage_resource(Parameters(ManageResourceInput { resource_type: "project".into(), action: "create".into(), @@ -4659,7 +4703,7 @@ mod tests { #[test] fn manage_unknown_type() { - let m = mcp(); + let (m, _guard) = mcp(); let result = m.manage_resource(Parameters(ManageResourceInput { resource_type: "widget".into(), action: "create".into(), @@ -4679,7 +4723,7 @@ mod tests { #[test] fn issue_create_and_get() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "TST"); let created = seed_issue(&m, "TST", "First issue"); assert!(created.contains("TST-1"), "got: {created}"); @@ -4694,7 +4738,8 @@ mod tests { #[test] fn issue_create_with_options() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Test", "OPT"); m.manage_resource(Parameters(ManageResourceInput { resource_type: "label".into(), @@ -4732,7 +4777,7 @@ mod tests { #[test] fn issue_update() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "UPI"); seed_issue(&m, "UPI", "Original"); @@ -4753,7 +4798,7 @@ mod tests { #[test] fn update_issue_without_linked_plan_steps_has_plain_response() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "PLN"); seed_issue(&m, "PLN", "Standalone"); @@ -4773,7 +4818,7 @@ mod tests { #[test] fn create_issue_persists_target_date() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "SCH"); let result = m.create_issue(Parameters(CreateIssueInput { @@ -4793,7 +4838,7 @@ mod tests { #[test] fn update_issue_sets_start_date() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "STD"); seed_issue(&m, "STD", "Original"); @@ -4812,7 +4857,7 @@ mod tests { #[test] fn update_issue_omitting_dates_leaves_them_unchanged() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "UNC"); m.create_issue(Parameters(CreateIssueInput { @@ -4842,7 +4887,8 @@ mod tests { #[test] fn bulk_update_sets_status_on_module_matches_only() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Bulk", "BLK"); // Module to target. m.manage_resource(Parameters(ManageResourceInput { @@ -4909,7 +4955,7 @@ mod tests { #[test] fn bulk_update_emits_issue_updates() { - let (m, mut rx) = mcp_with_realtime(); + let (m, mut rx, _guard) = mcp_with_realtime(); seed_project(&m, "Bulk Events", "BLE"); seed_issue(&m, "BLE", "One"); seed_issue(&m, "BLE", "Two"); @@ -4942,7 +4988,8 @@ mod tests { #[test] fn issue_delete() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Test", "DEL"); seed_issue(&m, "DEL", "Doomed"); @@ -4963,12 +5010,12 @@ mod tests { #[tokio::test] async fn issue_delete_keeps_the_deleted_resource_reference_plain() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "DEL"); seed_issue(&m, "DEL", "Doomed"); let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap(); - let result = crate::mcp::with_request_context(None, false, Some(context), || async { + let result = crate::mcp::with_request_context(None, Some(context), || async { m.delete(Parameters(DeleteInput { resource_type: "issue".into(), identifier: "DEL-01".into(), @@ -4982,7 +5029,7 @@ mod tests { #[test] fn get_nonexistent_issue_errors() { - let m = mcp(); + let (m, _guard) = mcp(); let result = m.get_issue(Parameters(GetIssueInput { identifier: "NOPE-999".into(), ..Default::default() @@ -4994,7 +5041,8 @@ mod tests { #[test] fn list_issues_with_filters() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Test", "LST"); m.create_issue(Parameters(CreateIssueInput { @@ -5036,7 +5084,7 @@ mod tests { #[test] fn list_issues_reads_link_context_once() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Context", "CTX"); seed_issue(&m, "CTX", "First"); seed_issue(&m, "CTX", "Second"); @@ -5053,7 +5101,7 @@ mod tests { #[test] fn list_issues_empty() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Empty", "EMP"); let result = m.list_issues(Parameters(ListIssuesInput { project: "EMP".into(), @@ -5071,7 +5119,7 @@ mod tests { #[test] fn list_issues_bad_project_errors() { - let m = mcp(); + let (m, _guard) = mcp(); // A project must exist, else the LIF-257 onboarding nudge fires // before the (bad) project identifier is ever resolved. seed_project(&m, "Alpha", "AAA"); @@ -5091,7 +5139,7 @@ mod tests { #[test] fn list_issues_pagination_emits_has_more_hint() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Pages", "PAG"); // Seed 5 issues; ask for 2 — should report has_more with offset=2. for i in 0..5 { @@ -5153,7 +5201,7 @@ mod tests { #[test] fn list_issues_no_hint_when_under_limit() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Small", "SML"); seed_issue(&m, "SML", "Only one"); let result = m.list_issues(Parameters(ListIssuesInput { @@ -5175,7 +5223,7 @@ mod tests { #[test] fn link_and_unlink_issues() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "LNK"); seed_issue(&m, "LNK", "Blocker"); seed_issue(&m, "LNK", "Blocked"); @@ -5207,7 +5255,7 @@ mod tests { // status, not a shared one. #[test] fn get_issue_relations_carry_status() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Rel", "REL"); seed_issue(&m, "REL", "target"); // REL-1 seed_issue(&m, "REL", "blocker-a"); // REL-2 @@ -5242,7 +5290,7 @@ mod tests { #[test] fn list_issues_blocked_filter_surfaces_blocked_by() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "BLK"); seed_issue(&m, "BLK", "Blocker"); // BLK-1 seed_issue(&m, "BLK", "Blocked"); // BLK-2 @@ -5268,7 +5316,8 @@ mod tests { #[test] fn board_groups_by_status() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Test", "BRD"); m.create_issue(Parameters(CreateIssueInput { project: "BRD".into(), @@ -5303,7 +5352,8 @@ mod tests { // LIF-140: board columns follow workflow order, not alphabetical order. #[test] fn board_status_columns_in_workflow_order() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Board Order", "BRO"); for status in ["done", "active", "backlog", "todo", "cancelled"] { m.create_issue(Parameters(CreateIssueInput { @@ -5341,7 +5391,8 @@ mod tests { #[test] fn board_priority_columns_in_severity_order() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Board Prio", "BRP"); for priority in ["none", "medium", "urgent", "low", "high"] { m.create_issue(Parameters(CreateIssueInput { @@ -5397,7 +5448,7 @@ mod tests { #[test] fn board_default_omits_closed_contents_but_shows_counts() { - let m = mcp(); + let (m, _guard) = mcp(); seed_board_mix(&m, "BCA"); let result = m.get_board(Parameters(GetBoardInput { project: "BCA".into(), @@ -5422,7 +5473,7 @@ mod tests { #[test] fn board_include_closed_shows_closed_issues() { - let m = mcp(); + let (m, _guard) = mcp(); seed_board_mix(&m, "BCB"); let result = m.get_board(Parameters(GetBoardInput { project: "BCB".into(), @@ -5437,7 +5488,7 @@ mod tests { #[test] fn board_priority_grouping_excludes_closed_with_trailing_note() { - let m = mcp(); + let (m, _guard) = mcp(); seed_board_mix(&m, "BCC"); let result = m.get_board(Parameters(GetBoardInput { project: "BCC".into(), @@ -5460,7 +5511,7 @@ mod tests { #[test] fn board_max_per_column_truncates_with_tail() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Capped Board", "BCD"); for i in 0..4 { m.create_issue(Parameters(CreateIssueInput { @@ -5487,7 +5538,7 @@ mod tests { #[test] fn board_empty_done_group_produces_no_stub() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "No Done", "BCE"); m.create_issue(Parameters(CreateIssueInput { project: "BCE".into(), @@ -5509,7 +5560,8 @@ mod tests { #[test] fn page_create_get_update() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Test", "PG"); let created = m.create_page(Parameters(CreatePageInput { @@ -5542,7 +5594,8 @@ mod tests { #[test] fn workspace_page_no_project() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); let created = m.create_page(Parameters(CreatePageInput { project: None, title: "Global Note".into(), @@ -5556,7 +5609,8 @@ mod tests { #[test] fn page_delete() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Test", "PGD"); m.create_page(Parameters(CreatePageInput { project: Some("PGD".into()), @@ -5578,7 +5632,7 @@ mod tests { #[test] fn search_finds_issue() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "SRC"); seed_issue(&m, "SRC", "Unique searchterm xyz"); @@ -5594,7 +5648,7 @@ mod tests { #[test] fn search_formats_issue_page_and_comment_results_distinctly() { - let m = mcp(); + let (m, _guard) = mcp(); let _guard = seed_user(&m); seed_project(&m, "Formatting", "FMT"); seed_issue(&m, "FMT", "Issue mixedformatneedle"); @@ -5632,7 +5686,7 @@ mod tests { #[tokio::test] async fn search_renders_a_comment_as_one_direct_link() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "SRC"); seed_issue(&m, "SRC", "Search comments"); let author = make_user(&m, "author", false); @@ -5645,7 +5699,7 @@ mod tests { let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap(); let result = - crate::mcp::with_request_context(Some(auth_user), false, Some(context), || async { + crate::mcp::with_request_context(Some(auth_user), Some(context), || async { m.add_comment(Parameters(AddCommentInput { identifier: "SRC-1".into(), content: "Unique comment needle".into(), @@ -5671,7 +5725,7 @@ mod tests { // away, and passes the snippet (with **needle**) through the MCP layer. #[test] fn mcp_search_literal_mode_finds_punctuation_needle() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "SRC"); seed_issue(&m, "SRC", "wire up core:sodom pipeline"); @@ -5687,7 +5741,7 @@ mod tests { #[test] fn mcp_search_invalid_mode_errors() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "SRC"); let result = m.search(Parameters(SearchInput { query: "anything".into(), @@ -5699,7 +5753,7 @@ mod tests { #[test] fn search_no_results() { - let m = mcp(); + let (m, _guard) = mcp(); // A project must exist, else the LIF-257 onboarding nudge fires // before the query is ever run. seed_project(&m, "Alpha", "AAA"); @@ -5716,7 +5770,7 @@ mod tests { #[test] fn list_resources_projects() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Alpha", "AAA"); seed_project(&m, "Beta", "BBB"); @@ -5736,7 +5790,7 @@ mod tests { #[test] fn list_resources_projects_shows_agent_stats_and_recent_work_first() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Stale", "STA"); seed_project(&m, "Recent", "REC"); seed_project(&m, "Empty", "EMP"); @@ -5790,7 +5844,7 @@ mod tests { #[test] fn nudge_list_resources_project_on_empty_db() { - let m = mcp(); + let (m, _guard) = mcp(); let result = m.list_resources(Parameters(ListResourcesInput { resource_type: "project".into(), ..Default::default() @@ -5800,7 +5854,7 @@ mod tests { #[test] fn nudge_list_issues_on_empty_db() { - let m = mcp(); + let (m, _guard) = mcp(); // Even with a bogus project filter, an empty DB nudges rather than // returning "project not found". let result = m.list_issues(Parameters(ListIssuesInput { @@ -5812,7 +5866,7 @@ mod tests { #[test] fn nudge_search_on_empty_db() { - let m = mcp(); + let (m, _guard) = mcp(); let result = m.search(Parameters(SearchInput { query: "anything".into(), ..Default::default() @@ -5822,7 +5876,7 @@ mod tests { #[test] fn nudge_get_board_on_empty_db() { - let m = mcp(); + let (m, _guard) = mcp(); let result = m.get_board(Parameters(GetBoardInput { project: "ANY".into(), ..Default::default() @@ -5832,7 +5886,7 @@ mod tests { #[test] fn no_nudge_once_a_project_exists() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Alpha", "AAA"); let listed = m.list_resources(Parameters(ListResourcesInput { @@ -5863,7 +5917,7 @@ mod tests { #[test] fn list_resources_requires_project() { - let m = mcp(); + let (m, _guard) = mcp(); for rt in ["module", "label", "folder", "issue"] { let result = m.list_resources(Parameters(ListResourcesInput { resource_type: rt.into(), @@ -5880,7 +5934,7 @@ mod tests { #[test] fn list_resources_unknown_type() { - let m = mcp(); + let (m, _guard) = mcp(); let result = m.list_resources(Parameters(ListResourcesInput { resource_type: "widget".into(), project: None, @@ -5895,7 +5949,7 @@ mod tests { #[test] fn list_resources_issues_pagination() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Bulk", "BLK"); for i in 0..4 { seed_issue(&m, "BLK", &format!("Issue {i}")); @@ -5917,7 +5971,8 @@ mod tests { #[test] fn delete_project() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Doomed", "DPJ"); let result = m.delete(Parameters(DeleteInput { resource_type: "project".into(), @@ -5929,7 +5984,8 @@ mod tests { #[test] fn delete_module_requires_project() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); let result = m.delete(Parameters(DeleteInput { resource_type: "module".into(), identifier: "Backend".into(), @@ -5940,7 +5996,8 @@ mod tests { #[test] fn delete_module_reports_the_canonical_module_name() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Modules", "MOD"); m.manage_resource(Parameters(ManageResourceInput { resource_type: "module".into(), @@ -5961,7 +6018,8 @@ mod tests { #[test] fn delete_unknown_type() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); let result = m.delete(Parameters(DeleteInput { resource_type: "widget".into(), identifier: "x".into(), @@ -5974,7 +6032,8 @@ mod tests { #[test] fn manage_update_label() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Test", "UPL"); m.manage_resource(Parameters(ManageResourceInput { resource_type: "label".into(), @@ -6006,7 +6065,8 @@ mod tests { #[test] fn manage_update_folder() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Test", "UPF"); m.manage_resource(Parameters(ManageResourceInput { resource_type: "folder".into(), @@ -6037,7 +6097,8 @@ mod tests { #[test] fn manage_resource_structure_mutations_emit_project_updates() { - let (m, mut rx) = mcp_with_realtime(); + let (m, mut rx, _guard) = mcp_with_realtime(); + let _ag = first_admin_guard(); seed_project(&m, "Structure Events", "STR"); let project_id = project_id_for(&m, "STR"); drain_realtime(&mut rx); @@ -6197,9 +6258,27 @@ mod tests { guard } + /// LIFIC-11: hold `MCP_HANDLER_LOCK` (the *production* serialization lock + /// that every `with_request_user`/`with_request_context` across BOTH + /// `mcp/mod.rs` and `mcp/tools.rs` acquires) with no request-user set, so a + /// direct-call test resolves to the first admin (seeded by `mcp()`) via + /// `resolve_caller` — mirroring a credential-less operator MCP request. + /// Holding THIS lock (not a test-only one) is what serializes the test with + /// every other global writer, eliminating the read/write race that + /// `mcp_gate`'s legacy short-circuit used to mask. Only safe in tests that + /// do NOT themselves call `with_request_user`/`with_request_context`/ + /// `seed_user` (they'd re-acquire the non-reentrant lock and deadlock). + fn first_admin_guard() -> tokio::sync::MutexGuard<'static, ()> { + let guard = crate::mcp::MCP_HANDLER_LOCK.blocking_lock(); + *crate::mcp::MCP_REQUEST_USER + .lock() + .unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner()) = None; + guard + } + #[test] fn add_and_list_comments() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Test issue"); let _guard = seed_user(&m); @@ -6238,7 +6317,7 @@ mod tests { // fixture, so all non-bot users are candidates). #[test] fn add_comment_records_mentions() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Mention issue"); let _guard = seed_user(&m); @@ -6282,7 +6361,7 @@ mod tests { #[test] fn get_issue_includes_comments() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Commented issue"); let _guard = seed_user(&m); @@ -6314,7 +6393,7 @@ mod tests { #[test] fn get_issue_recent_truncates_over_three_comments() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Chatty issue"); let _guard = seed_user(&m); @@ -6339,7 +6418,7 @@ mod tests { #[test] fn get_issue_recent_unchanged_at_three_or_fewer() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Few comments"); let _guard = seed_user(&m); @@ -6356,7 +6435,7 @@ mod tests { #[test] fn get_issue_all_shows_every_comment() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Full history"); let _guard = seed_user(&m); @@ -6377,7 +6456,7 @@ mod tests { #[test] fn get_issue_none_emits_stub_only() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Suppressed"); let _guard = seed_user(&m); @@ -6396,7 +6475,7 @@ mod tests { #[test] fn get_issue_none_with_zero_comments_shows_nothing() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Quiet"); @@ -6409,7 +6488,7 @@ mod tests { #[test] fn get_issue_invalid_include_comments_errors() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Bad mode"); @@ -6425,7 +6504,7 @@ mod tests { #[test] fn list_comments_limit_paginates_with_hint() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Limited"); let _guard = seed_user(&m); @@ -6452,7 +6531,7 @@ mod tests { #[test] fn list_comments_limit_desc_returns_newest_first() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Newest N"); let _guard = seed_user(&m); @@ -6476,7 +6555,7 @@ mod tests { #[test] fn list_comments_offset_returns_next_page() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Paged"); let _guard = seed_user(&m); @@ -6504,7 +6583,7 @@ mod tests { #[test] fn list_comments_no_limit_keeps_plain_header() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Unlimited"); let _guard = seed_user(&m); @@ -6530,7 +6609,7 @@ mod tests { #[test] fn list_comments_offset_without_limit_returns_unbounded_remainder() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Offset remainder"); let _guard = seed_user(&m); @@ -6557,7 +6636,7 @@ mod tests { #[test] fn list_comments_offset_past_end_reports_total() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Exhausted"); let _guard = seed_user(&m); @@ -6575,7 +6654,7 @@ mod tests { #[test] fn list_comments_with_zero_comments_reports_empty_thread() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "No comments"); @@ -6588,7 +6667,7 @@ mod tests { #[test] fn add_comment_bad_identifier() { - let m = mcp(); + let (m, _guard) = mcp(); let _guard = seed_user(&m); let result = m.add_comment(Parameters(AddCommentInput { @@ -6600,29 +6679,15 @@ mod tests { #[test] fn add_comment_falls_back_to_first_admin() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Test issue"); - // Create an admin user but do NOT set MCP_REQUEST_USER — simulates stdio/local auth. - let conn = m.db.write().unwrap(); - queries::users::create_user( - &conn, - &models::CreateUser { - username: "admin".into(), - email: "admin@local.test".into(), - password: "adminpass123".into(), - display_name: Some("Admin User".into()), - is_admin: true, - is_bot: false, - }, - ) - .unwrap(); - drop(conn); - - // Clear any leftover auth context. Holds MCP_HANDLER_LOCK (see - // `seed_user`'s doc comment) so this "clear, then rely on it staying - // None" window can't be raced by a concurrently-running + // mcp() already seeded the first admin; here we deliberately do NOT set + // MCP_REQUEST_USER — simulating a stdio/local-auth session with no + // bound user. Clear any leftover auth context. Holds MCP_HANDLER_LOCK + // (see `seed_user`'s doc comment) so this "clear, then rely on it + // staying None" window can't be raced by a concurrently-running // `with_request_user` caller in another test. let _guard = crate::mcp::MCP_HANDLER_LOCK.blocking_lock(); *crate::mcp::MCP_REQUEST_USER @@ -6641,7 +6706,7 @@ mod tests { #[test] fn add_comment_on_page_identifier_creates_page_comment() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Pages", "PGC"); m.create_page(Parameters(CreatePageInput { project: Some("PGC".into()), @@ -6674,7 +6739,7 @@ mod tests { #[test] fn project_page_comment_mutations_emit_project_updates() { - let (m, mut rx) = mcp_with_realtime(); + let (m, mut rx, _guard) = mcp_with_realtime(); seed_project(&m, "Page Comments", "PCO"); let project_id = project_id_for(&m, "PCO"); m.create_page(Parameters(CreatePageInput { @@ -6718,7 +6783,7 @@ mod tests { #[test] fn page_and_issue_comments_do_not_cross_contaminate_via_mcp() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Mix", "MIX"); seed_issue(&m, "MIX", "An issue"); m.create_page(Parameters(CreatePageInput { @@ -6766,7 +6831,7 @@ mod tests { #[test] fn add_comment_on_workspace_page() { - let m = mcp(); + let (m, _guard) = mcp(); // Workspace pages have no project prefix: identifier is DOC-N. m.create_page(Parameters(CreatePageInput { project: None, @@ -6869,7 +6934,7 @@ mod tests { #[test] fn edit_issue_unique_match_succeeds() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "EDI"); seed_issue_with_description(&m, "EDI", "T", "The quick brown fox"); @@ -6892,7 +6957,7 @@ mod tests { #[test] fn export_dispatches_on_identifier_shape() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "EXP"); seed_issue_with_description(&m, "EXP", "Ship it", "issue body here"); let created = m.create_page(Parameters(CreatePageInput { @@ -6933,7 +6998,7 @@ mod tests { #[test] fn create_and_edit_issue_preserves_literal_escapes_in_multiline_code() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "ESC"); let description = "Example:\n```c\nprintf(\"\\n\");\n```\n"; let created = m.create_issue(Parameters(CreateIssueInput { @@ -6983,7 +7048,7 @@ mod tests { #[test] fn edit_issue_emits_issue_update() { - let (m, mut rx) = mcp_with_realtime(); + let (m, mut rx, _guard) = mcp_with_realtime(); seed_project(&m, "Edit Events", "EDE"); seed_issue_with_description(&m, "EDE", "T", "hello world"); let project_id = project_id_for(&m, "EDE"); @@ -7010,7 +7075,7 @@ mod tests { #[test] fn edit_issue_no_match_fails_with_clear_error() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "EDN"); seed_issue_with_description(&m, "EDN", "T", "hello world"); @@ -7034,7 +7099,7 @@ mod tests { #[test] fn edit_issue_multiple_match_fails_without_replace_all() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "EDM"); seed_issue_with_description(&m, "EDM", "T", "foo foo foo"); @@ -7052,7 +7117,7 @@ mod tests { #[test] fn edit_issue_replace_all_succeeds_when_set() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "EDA"); seed_issue_with_description(&m, "EDA", "T", "foo foo foo"); @@ -7074,7 +7139,7 @@ mod tests { #[test] fn edit_issue_empty_old_string_fails() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "EDE"); seed_issue_with_description(&m, "EDE", "T", "anything"); @@ -7091,7 +7156,7 @@ mod tests { #[test] fn edit_issue_identical_old_new_fails() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "EDS"); seed_issue_with_description(&m, "EDS", "T", "hello"); @@ -7108,7 +7173,7 @@ mod tests { #[test] fn edit_issue_title_field_works() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "EDT"); seed_issue_with_description(&m, "EDT", "Old name here", "body"); @@ -7132,7 +7197,7 @@ mod tests { #[test] fn edit_issue_invalid_field_fails() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Test", "EDX"); seed_issue_with_description(&m, "EDX", "T", "body"); @@ -7149,7 +7214,8 @@ mod tests { #[test] fn edit_issue_preserves_other_fields() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Test", "EDP"); m.create_issue(Parameters(CreateIssueInput { project: "EDP".into(), @@ -7187,7 +7253,8 @@ mod tests { #[test] fn edit_page_content_works() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Test", "EPC"); m.create_page(Parameters(CreatePageInput { project: Some("EPC".into()), @@ -7216,7 +7283,8 @@ mod tests { #[test] fn project_scoped_page_mutations_emit_project_updates() { - let (m, mut rx) = mcp_with_realtime(); + let (m, mut rx, _guard) = mcp_with_realtime(); + let _ag = first_admin_guard(); seed_project(&m, "Page Events", "PGE"); let project_id = project_id_for(&m, "PGE"); drain_realtime(&mut rx); @@ -7262,7 +7330,8 @@ mod tests { #[test] fn edit_page_title_field_works() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Test", "EPT"); m.create_page(Parameters(CreatePageInput { project: Some("EPT".into()), @@ -7286,7 +7355,8 @@ mod tests { #[test] fn edit_page_preserves_other_fields() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Test", "EPP"); // Folder so we can verify it's preserved. m.manage_resource(Parameters(ManageResourceInput { @@ -7346,7 +7416,8 @@ mod tests { #[test] fn edit_page_no_match_fails() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Test", "EPN"); m.create_page(Parameters(CreatePageInput { project: Some("EPN".into()), @@ -7370,7 +7441,8 @@ mod tests { #[test] fn edit_page_invalid_field_fails() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Test", "EPX"); m.create_page(Parameters(CreatePageInput { project: Some("EPX".into()), @@ -7415,7 +7487,8 @@ mod tests { #[test] fn mcp_create_page_with_labels_returns_them_in_get() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_labels_for_pages(&m, "PGL", "Pages with Labels"); let created = m.create_page(Parameters(CreatePageInput { @@ -7437,7 +7510,8 @@ mod tests { #[test] fn mcp_update_page_replaces_labels() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_labels_for_pages(&m, "PUL", "Page Update Labels"); m.create_page(Parameters(CreatePageInput { project: Some("PUL".into()), @@ -7467,7 +7541,8 @@ mod tests { #[test] fn mcp_update_issue_clears_module_with_empty_string() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Clear Module", "CLM"); m.manage_resource(Parameters(ManageResourceInput { resource_type: "module".into(), @@ -7529,7 +7604,8 @@ mod tests { /// empty-string sentinel (folder_id = NULL). #[test] fn mcp_update_page_clears_folder_with_empty_string() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Clear Folder", "CLF"); m.manage_resource(Parameters(ManageResourceInput { resource_type: "folder".into(), @@ -7589,7 +7665,8 @@ mod tests { /// sentinel (emoji = NULL). #[test] fn mcp_manage_resource_sets_then_clears_project_emoji() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Emoji Project", "EMP"); // Set emoji. @@ -7636,7 +7713,8 @@ mod tests { #[test] fn mcp_manage_resource_sets_module_emoji() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Module Emoji", "MEM"); m.manage_resource(Parameters(ManageResourceInput { resource_type: "module".into(), @@ -7680,7 +7758,8 @@ mod tests { // `- {id} | {status} | {title}[ [labels]][ (folder: F)] — updated {date}` // — matches the issue list formatter so an agent reading both // surfaces sees one mental model. - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_labels_for_pages(&m, "PLI", "Page List"); m.create_page(Parameters(CreatePageInput { project: Some("PLI".into()), @@ -7717,7 +7796,8 @@ mod tests { #[test] fn mcp_list_resources_pages_label_filter() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_labels_for_pages(&m, "PLF", "Page Label Filter"); m.create_page(Parameters(CreatePageInput { project: Some("PLF".into()), @@ -7751,7 +7831,8 @@ mod tests { #[test] fn mcp_workspace_page_create_with_labels_silently_drops_them() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); // No seed_project: workspace pages live outside any project. The // labels list is silently ignored (project-scoped labels can't // attach without a project). @@ -7776,7 +7857,8 @@ mod tests { #[test] fn mcp_get_page_surfaces_status_folder_and_timestamps() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Meta", "MET"); m.manage_resource(Parameters(ManageResourceInput { resource_type: "folder".into(), @@ -7819,7 +7901,8 @@ mod tests { #[test] fn mcp_get_page_without_folder_says_none() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Meta", "MET"); m.create_page(Parameters(CreatePageInput { project: Some("MET".into()), @@ -7841,7 +7924,8 @@ mod tests { #[test] fn mcp_list_resources_pages_filters_by_status() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Stat", "STA"); m.create_page(Parameters(CreatePageInput { project: Some("STA".into()), @@ -7874,7 +7958,8 @@ mod tests { #[test] fn mcp_list_resources_pages_orders_by_title_desc() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Ord", "ORD"); for title in ["Alpha", "Zulu", "Mike"] { m.create_page(Parameters(CreatePageInput { @@ -7902,7 +7987,8 @@ mod tests { #[test] fn mcp_list_resources_pages_shows_folder_name() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Fold", "FOL"); m.manage_resource(Parameters(ManageResourceInput { resource_type: "folder".into(), @@ -7937,7 +8023,8 @@ mod tests { #[test] fn mcp_list_resources_pages_respects_limit() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Pag", "PAG"); for title in ["P1", "P2", "P3", "P4", "P5"] { m.create_page(Parameters(CreatePageInput { @@ -7972,7 +8059,8 @@ mod tests { #[test] fn mcp_list_resources_pages_offset_pages_correctly() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Off", "OFF"); // Deterministic order: sort by title asc so we know which page lands // on which offset. @@ -8005,7 +8093,8 @@ mod tests { #[test] fn mcp_list_resources_pages_hint_absent_on_last_page() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Last", "LST"); for title in ["X", "Y", "Z"] { m.create_page(Parameters(CreatePageInput { @@ -8036,7 +8125,7 @@ mod tests { #[test] fn mcp_list_comments_author_filter() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Authored"); let _guard = seed_user(&m); @@ -8066,7 +8155,7 @@ mod tests { #[test] fn mcp_list_comments_desc_order() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Threaded"); let _guard = seed_user(&m); @@ -8152,7 +8241,7 @@ mod tests { #[test] fn edit_comment_author_can_edit_own() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Editable"); let author = make_user(&m, "author", false); @@ -8192,7 +8281,7 @@ mod tests { #[test] fn delete_comment_author_can_delete_own() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Deletable"); let author = make_user(&m, "author", false); @@ -8222,7 +8311,7 @@ mod tests { #[tokio::test] async fn comment_mutations_link_the_live_comment_or_parent() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Linked comments"); let author = make_user(&m, "author", false); @@ -8235,7 +8324,7 @@ mod tests { let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap(); let (comment_id, edited, deleted) = - crate::mcp::with_request_context(Some(auth_user), false, Some(context), || async { + crate::mcp::with_request_context(Some(auth_user), Some(context), || async { let added = m.add_comment(Parameters(AddCommentInput { identifier: "PRJ-1".into(), content: "original".into(), @@ -8266,7 +8355,7 @@ mod tests { #[test] fn issue_comment_edit_and_delete_emit_updates() { - let (m, mut events) = mcp_with_realtime(); + let (m, mut events, _guard) = mcp_with_realtime(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Realtime comments"); let project_id = project_id_for(&m, "PRJ"); @@ -8305,7 +8394,7 @@ mod tests { #[test] fn edit_and_delete_comment_refuse_non_author_non_admin() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Guarded"); let author = make_user(&m, "author", false); @@ -8348,7 +8437,7 @@ mod tests { #[test] fn admin_can_delete_another_users_comment() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "AdminTarget"); let author = make_user(&m, "author", false); @@ -8373,7 +8462,7 @@ mod tests { #[test] fn edit_and_delete_comment_unknown_id_errors_cleanly() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "Empty"); let author = make_user(&m, "author", false); @@ -8395,7 +8484,8 @@ mod tests { #[test] fn mcp_search_result_type_filter() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Proj", "PRJ"); seed_issue(&m, "PRJ", "findable widget issue"); m.create_page(Parameters(CreatePageInput { @@ -8425,7 +8515,7 @@ mod tests { #[test] fn mcp_search_pagination_emits_has_more_hint() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Proj", "PRJ"); for i in 0..3 { seed_issue(&m, "PRJ", &format!("paginated result {i}")); @@ -8451,7 +8541,7 @@ mod tests { #[test] fn mcp_list_issues_date_filters() { - let m = mcp(); + let (m, _guard) = mcp(); let ident = seed_project(&m, "Dated", "DAT"); seed_issue(&m, &ident, "Recent issue"); @@ -8474,7 +8564,7 @@ mod tests { #[test] fn mcp_list_issues_order_by_sequence_desc() { - let m = mcp(); + let (m, _guard) = mcp(); let ident = seed_project(&m, "Sorted", "SRT"); seed_issue(&m, &ident, "Oldest"); seed_issue(&m, &ident, "Newest"); @@ -8501,7 +8591,7 @@ mod tests { #[test] fn get_activity_renders_issue_history() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Audit", "TST"); seed_issue(&m, "TST", "Watched issue"); @@ -8522,7 +8612,7 @@ mod tests { #[test] fn get_activity_project_feed_pages_with_hint() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Audit", "TST"); for i in 0..4 { seed_issue(&m, "TST", &format!("issue {i}")); @@ -8548,30 +8638,35 @@ mod tests { #[tokio::test] async fn get_activity_links_the_resolved_resource_type() { - let m = mcp(); - seed_project(&m, "Audit", "TST"); - seed_issue(&m, "TST", "Audit issue"); - m.create_page(Parameters(CreatePageInput { - project: Some("TST".into()), - title: "Audit page".into(), - content: None, - folder: None, - status: None, - labels: None, - })); - m.create_plan(Parameters(CreatePlanInput { - project: "TST".into(), - title: "Audit plan".into(), - anchor_issue: None, - steps: None, - })); - m.manage_resource(Parameters(ManageResourceInput { - resource_type: "module".into(), - action: "create".into(), - project: Some("TST".into()), - name: Some("Backend".into()), - ..Default::default() - })); + let (m, _guard) = mcp(); + // Setup as the first admin (credential-less → resolve_caller fallback), + // holding the handler lock so the Lead-gated module create is stable. + crate::mcp::with_request_context(None, None, || async { + seed_project(&m, "Audit", "TST"); + seed_issue(&m, "TST", "Audit issue"); + m.create_page(Parameters(CreatePageInput { + project: Some("TST".into()), + title: "Audit Page".into(), + content: None, + folder: None, + status: None, + labels: None, + })); + m.create_plan(Parameters(CreatePlanInput { + project: "TST".into(), + title: "Audit plan".into(), + anchor_issue: None, + steps: None, + })); + m.manage_resource(Parameters(ManageResourceInput { + resource_type: "module".into(), + action: "create".into(), + project: Some("TST".into()), + name: Some("Backend".into()), + ..Default::default() + })); + }) + .await; let author = make_user(&m, "author", false); let auth_user = models::AuthUser { id: author.id, @@ -8582,7 +8677,7 @@ mod tests { let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap(); let (project, page, issue) = - crate::mcp::with_request_context(Some(auth_user), false, Some(context), || async { + crate::mcp::with_request_context(Some(auth_user), Some(context), || async { m.add_comment(Parameters(AddCommentInput { identifier: "TST-1".into(), content: "Audit comment".into(), @@ -8627,20 +8722,25 @@ mod tests { #[tokio::test] async fn get_activity_leaves_stale_identifiers_unlinked_after_project_rename() { - let m = mcp(); - seed_project(&m, "Audit", "TST"); - seed_issue(&m, "TST", "Historical issue"); - let updated = m.manage_resource(Parameters(ManageResourceInput { - resource_type: "project".into(), - action: "update".into(), - project: Some("TST".into()), - identifier: Some("NEW".into()), - ..Default::default() - })); + let (m, _guard) = mcp(); + // Setup as the first admin (credential-less → resolve_caller fallback), + // holding the handler lock so the Lead-gated project rename is stable. + let updated = crate::mcp::with_request_context(None, None, || async { + seed_project(&m, "Audit", "TST"); + seed_issue(&m, "TST", "Historical issue"); + m.manage_resource(Parameters(ManageResourceInput { + resource_type: "project".into(), + action: "update".into(), + project: Some("TST".into()), + identifier: Some("NEW".into()), + ..Default::default() + })) + }) + .await; assert!(updated.contains("Updated project NEW"), "got: {updated}"); let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap(); - let out = crate::mcp::with_request_context(None, false, Some(context), || async { + let out = crate::mcp::with_request_context(None, Some(context), || async { m.get_activity(Parameters(GetActivityInput { identifier: "NEW".into(), ..Default::default() @@ -8654,7 +8754,7 @@ mod tests { #[tokio::test] async fn get_activity_leaves_stale_relation_targets_unlinked_after_project_rename() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Audit", "TST"); seed_issue(&m, "TST", "Source"); seed_issue(&m, "TST", "Target"); @@ -8667,7 +8767,7 @@ mod tests { let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap(); let (live, stale) = - crate::mcp::with_request_context(None, false, Some(context), || async { + crate::mcp::with_request_context(None, Some(context), || async { let live = m.get_activity(Parameters(GetActivityInput { identifier: "TST".into(), ..Default::default() @@ -8702,7 +8802,7 @@ mod tests { #[tokio::test] async fn get_activity_links_plan_steps_to_their_parent_plan() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Audit", "TST"); let created = m.create_plan(Parameters(CreatePlanInput { project: "TST".into(), @@ -8716,7 +8816,7 @@ mod tests { assert!(created.contains("Created TST-PLAN-1"), "got: {created}"); let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap(); - let out = crate::mcp::with_request_context(None, false, Some(context), || async { + let out = crate::mcp::with_request_context(None, Some(context), || async { m.get_activity(Parameters(GetActivityInput { identifier: "TST".into(), ..Default::default() @@ -8734,7 +8834,7 @@ mod tests { #[tokio::test] async fn get_activity_links_plan_step_issue_values() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Audit", "TST"); seed_issue(&m, "TST", "Linked issue"); m.create_plan(Parameters(CreatePlanInput { @@ -8755,7 +8855,7 @@ mod tests { assert!(updated.contains("Attached TST-1"), "got: {updated}"); let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap(); - let out = crate::mcp::with_request_context(None, false, Some(context), || async { + let out = crate::mcp::with_request_context(None, Some(context), || async { m.get_activity(Parameters(GetActivityInput { identifier: "TST".into(), ..Default::default() @@ -8773,7 +8873,7 @@ mod tests { #[test] fn get_activity_rejects_unknown_identifier() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Audit", "TST"); let out = m.get_activity(Parameters(GetActivityInput { identifier: "NOPE-999".into(), @@ -8784,7 +8884,7 @@ mod tests { #[tokio::test] async fn get_activity_attributes_mcp_actor() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Audit", "TST"); // Seed a bot user, then act through the production MCP identity @@ -8825,7 +8925,7 @@ mod tests { /// This test reproduces the boundary with a literal tokio::spawn. #[tokio::test] async fn mcp_attribution_survives_task_spawn() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Audit", "TST"); let bot_id = { @@ -8880,7 +8980,7 @@ mod tests { #[test] fn create_plan_authors_nested_tree_and_get_plan_rehydrates() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Plans", "PLN"); let created = m.create_plan(Parameters(CreatePlanInput { @@ -8926,7 +9026,7 @@ mod tests { #[tokio::test] async fn attaching_an_issue_to_a_plan_step_links_its_canonical_identifier() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Plans", "PLN"); seed_issue(&m, "PLN", "Real work"); let created = m.create_plan(Parameters(CreatePlanInput { @@ -8946,7 +9046,7 @@ mod tests { .expect("step id in output"); let context = crate::links::IssueLinkContext::parse("https://tracker.example").unwrap(); - let output = crate::mcp::with_request_context(None, false, Some(context), || async { + let output = crate::mcp::with_request_context(None, Some(context), || async { m.update_plan_step(Parameters(UpdatePlanStepInput { plan: "PLN-PLAN-1".into(), step_id: Some(step_id), @@ -8965,7 +9065,7 @@ mod tests { #[test] fn update_plan_step_done_closes_linked_issue_and_narrates() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Plans", "PLN"); seed_issue(&m, "PLN", "Real work"); // PLN-1 @@ -9020,7 +9120,7 @@ mod tests { // LIF-302: echo_tree=true restores the full re-rendered plan tree. #[test] fn update_plan_step_echo_tree_returns_full_tree() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Plans", "PET"); let created = m.create_plan(Parameters(CreatePlanInput { project: "PET".into(), @@ -9064,7 +9164,7 @@ mod tests { // receipt, not the tree. #[test] fn update_plan_step_plan_level_returns_receipt() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Plans", "PPR"); m.create_plan(Parameters(CreatePlanInput { project: "PPR".into(), @@ -9095,7 +9195,7 @@ mod tests { #[test] fn update_plan_step_done_emits_issue_update() { - let (m, mut rx) = mcp_with_realtime(); + let (m, mut rx, _guard) = mcp_with_realtime(); seed_project(&m, "Plan Events", "PLE"); seed_issue(&m, "PLE", "Real work"); let project_id = project_id_for(&m, "PLE"); @@ -9140,7 +9240,7 @@ mod tests { #[test] fn edit_plan_step_find_replace() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Plans", "PLN"); let created = m.create_plan(Parameters(CreatePlanInput { project: "PLN".into(), @@ -9176,7 +9276,7 @@ mod tests { #[test] fn plan_level_update_archives_and_lists() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Plans", "PLN"); m.create_plan(Parameters(CreatePlanInput { project: "PLN".into(), @@ -9220,7 +9320,7 @@ mod tests { // with provenance when the plan is rehydrated. #[test] fn closing_issue_autocompletes_step_visible_in_get_plan() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Plans", "PLN"); seed_issue(&m, "PLN", "Mirrored work"); // PLN-1 let created = m.create_plan(Parameters(CreatePlanInput { @@ -9262,7 +9362,7 @@ mod tests { #[test] fn reopening_issue_narrates_reopened_plan_step() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Plans", "RPN"); seed_issue(&m, "RPN", "Mirrored work"); // RPN-1 let created = m.create_plan(Parameters(CreatePlanInput { @@ -9301,7 +9401,7 @@ mod tests { #[test] fn closing_issue_skips_steps_in_archived_plans_without_note() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Plans", "ARC"); seed_issue(&m, "ARC", "Mirrored work"); // ARC-1 m.create_plan(Parameters(CreatePlanInput { @@ -9341,7 +9441,8 @@ mod tests { #[test] fn delete_plan_via_delete_tool() { - let m = mcp(); + let (m, _guard) = mcp(); + let _ag = first_admin_guard(); seed_project(&m, "Plans", "PLN"); m.create_plan(Parameters(CreatePlanInput { project: "PLN".into(), @@ -9389,7 +9490,7 @@ mod tests { #[test] fn no_html_escape_across_issue_read_surfaces() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Escape", "ESC"); let created = m.create_issue(Parameters(CreateIssueInput { project: "ESC".into(), @@ -9445,7 +9546,7 @@ mod tests { #[test] fn no_html_escape_in_comment_surfaces() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Escape", "ESC"); seed_issue(&m, "ESC", "Host issue"); let _guard = seed_user(&m); @@ -9473,7 +9574,7 @@ mod tests { #[test] fn no_html_escape_in_plan_step_title() { - let m = mcp(); + let (m, _guard) = mcp(); seed_project(&m, "Escape", "ESC"); let raw_step = r#"land A & B "now""#; let created = m.create_plan(Parameters(CreatePlanInput { @@ -9533,7 +9634,7 @@ mod authz_gating_tests { #[test] fn bulk_update_denies_non_member_when_enforced() { - let (m, _admin, _lead, maintainer, _viewer, non_member, _project_id) = + let (m, _admin, _lead, maintainer, _viewer, non_member, _project_id, _guard) = setup_membership_mcp(); // Seed an active issue as a permitted member. let created = as_user(&maintainer, || { @@ -9577,7 +9678,7 @@ mod authz_gating_tests { #[test] fn issue_read_denies_non_member_allows_viewer() { - let (m, _admin, lead, _maintainer, viewer, non_member, project_id) = setup_membership_mcp(); + let (m, _admin, lead, _maintainer, viewer, non_member, project_id, _guard) = setup_membership_mcp(); let _ = project_id; let created = as_user(&lead, || { m.create_issue(Parameters(CreateIssueInput { @@ -9613,7 +9714,7 @@ mod authz_gating_tests { #[test] fn page_and_plan_reads_follow_the_same_viewer_gate() { - let (m, _admin, lead, _maintainer, viewer, non_member, _project_id) = + let (m, _admin, lead, _maintainer, viewer, non_member, _project_id, _guard) = setup_membership_mcp(); let page = as_user(&lead, || { m.create_page(Parameters(CreatePageInput { @@ -9667,7 +9768,7 @@ mod authz_gating_tests { #[test] fn search_and_list_resources_project_filter_instead_of_denying() { - let (m, _admin, lead, _maintainer, viewer, non_member, _project_id) = + let (m, _admin, lead, _maintainer, viewer, non_member, _project_id, _guard) = setup_membership_mcp(); let created = as_user(&lead, || { m.create_issue(Parameters(CreateIssueInput { @@ -9728,7 +9829,7 @@ mod authz_gating_tests { /// projects exist and mislead a real member. #[test] fn nudge_not_shown_when_projects_exist_but_none_visible() { - let (m, _admin, _lead, _maintainer, _viewer, non_member, _project_id) = + let (m, _admin, _lead, _maintainer, _viewer, non_member, _project_id, _guard) = setup_membership_mcp(); let projects = as_user(&non_member, || { @@ -9759,7 +9860,7 @@ mod authz_gating_tests { #[test] fn issue_create_gated_by_maintainer_role() { - let (m, admin, lead, maintainer, viewer, non_member, _project_id) = setup_membership_mcp(); + let (m, admin, lead, maintainer, viewer, non_member, _project_id, _guard) = setup_membership_mcp(); for (user, expect_ok) in [ (&non_member, false), @@ -9791,7 +9892,7 @@ mod authz_gating_tests { #[test] fn issue_update_and_delete_gated_by_maintainer_role() { - let (m, _admin, lead, maintainer, viewer, non_member, _project_id) = setup_membership_mcp(); + let (m, _admin, lead, maintainer, viewer, non_member, _project_id, _guard) = setup_membership_mcp(); let created = as_user(&maintainer, || { m.create_issue(Parameters(CreateIssueInput { project: "MEM".into(), @@ -9855,7 +9956,7 @@ mod authz_gating_tests { #[test] fn comment_create_allows_viewer_denies_non_member() { - let (m, _admin, lead, _maintainer, viewer, non_member, _project_id) = + let (m, _admin, lead, _maintainer, viewer, non_member, _project_id, _guard) = setup_membership_mcp(); let created = as_user(&lead, || { m.create_issue(Parameters(CreateIssueInput { @@ -9895,7 +9996,7 @@ mod authz_gating_tests { #[test] fn structure_endpoints_viewer_denied_maintainer_allowed() { - let (m, _admin, _lead, maintainer, viewer, non_member, _project_id) = + let (m, _admin, _lead, maintainer, viewer, non_member, _project_id, _guard) = setup_membership_mcp(); let denied = as_user(&viewer, || { @@ -9993,7 +10094,7 @@ mod authz_gating_tests { #[test] fn project_settings_update_maintainer_denied_lead_allowed() { - let (m, _admin, lead, maintainer, _viewer, _non_member, _project_id) = + let (m, _admin, lead, maintainer, _viewer, _non_member, _project_id, _guard) = setup_membership_mcp(); let denied = as_user(&maintainer, || { @@ -10031,7 +10132,7 @@ mod authz_gating_tests { #[test] fn project_delete_maintainer_denied_lead_allowed_when_enforced() { - let (m, _admin, lead, maintainer, _viewer, _non_member, _project_id) = + let (m, _admin, lead, maintainer, _viewer, _non_member, _project_id, _guard) = setup_membership_mcp(); let denied = as_user(&maintainer, || { @@ -10057,7 +10158,7 @@ mod authz_gating_tests { #[test] fn relation_link_requires_maintainer_on_both_projects() { - let (m, _admin, lead, maintainer, _viewer, _non_member, project_id) = + let (m, _admin, lead, maintainer, _viewer, _non_member, project_id, _guard) = setup_membership_mcp(); let issue_a = as_user(&lead, || { m.create_issue(Parameters(CreateIssueInput { @@ -10143,7 +10244,7 @@ mod authz_gating_tests { /// step requires Maintainer on that issue's project too. #[test] fn plan_step_attach_issue_requires_maintainer_on_issue_project() { - let (m, _admin, lead, maintainer, _viewer, _non_member, _project_id) = + let (m, _admin, lead, maintainer, _viewer, _non_member, _project_id, _guard) = setup_membership_mcp(); let plan = as_user(&maintainer, || { m.create_plan(Parameters(CreatePlanInput { @@ -10232,7 +10333,7 @@ mod authz_gating_tests { #[test] fn workspace_page_mutation_requires_admin() { - let (m, admin, _lead, maintainer, _viewer, _non_member, _project_id) = + let (m, admin, _lead, maintainer, _viewer, _non_member, _project_id, _guard) = setup_membership_mcp(); let denied = as_user(&maintainer, || { @@ -10264,7 +10365,7 @@ mod authz_gating_tests { #[test] fn bot_owned_by_maintainer_inherits_role() { - let (m, _admin, _lead, maintainer, _viewer, _non_member, _project_id) = + let (m, _admin, _lead, maintainer, _viewer, _non_member, _project_id, _guard) = setup_membership_mcp(); let bot = { let conn = m.db.write().unwrap(); @@ -10312,7 +10413,7 @@ mod authz_gating_tests { #[test] fn non_member_denied_on_reads_mutations_and_delete() { - let (m, _admin, lead, _maintainer, _viewer, non_member, _project_id) = + let (m, _admin, lead, _maintainer, _viewer, non_member, _project_id, _guard) = setup_membership_mcp(); let created = as_user(&lead, || { m.create_issue(Parameters(CreateIssueInput { @@ -10365,7 +10466,7 @@ mod authz_gating_tests { // write side through a tool call; this adds the READ side through // an actual tool call on a project the admin holds no membership // row on at all. - let (m, admin, lead, _maintainer, _viewer, _non_member, _project_id) = + let (m, admin, lead, _maintainer, _viewer, _non_member, _project_id, _guard) = setup_membership_mcp(); let created = as_user(&lead, || { m.create_issue(Parameters(CreateIssueInput { @@ -10437,7 +10538,7 @@ mod authz_gating_tests { use sha2::{Digest, Sha256}; use tower::ServiceExt; - let (m, _admin, lead, maintainer, _viewer, non_member, _project_id) = + let (m, _admin, lead, maintainer, _viewer, non_member, _project_id, _guard) = setup_membership_mcp(); let created = as_user(&lead, || { m.create_issue(Parameters(CreateIssueInput { @@ -10658,6 +10759,7 @@ mod authz_gating_tests { (lead, outsider) }; let m = LificMcp::new(db); + let _sguard = crate::mcp::tools::acquire_test_guard(); let outsider_user = models::AuthUser { id: outsider.id, username: outsider.username, diff --git a/src/oauth.rs b/src/oauth.rs index 52781e2a..2f0a9e88 100644 --- a/src/oauth.rs +++ b/src/oauth.rs @@ -14,6 +14,7 @@ use sha2::{Digest, Sha256}; use tracing::{info, warn}; use crate::db::DbPool; +use crate::error::LificError; use crate::ratelimit::RateLimiter; type HmacSha256 = Hmac; @@ -391,6 +392,24 @@ async fn register_client( .into_response() } +/// LIFIC-15: read the tool a registered client has been mapped to, if any. +/// +/// Remembering the tool per client means a reconnect pre-fills (or skips) the +/// approval pick-list instead of re-asking — the choice is a stable attribute +/// of the persistent DCR client, not re-derived on every visit. Returns `None` +/// for clients that have never been approved. (Writing happens inside +/// [`resolve_approval_bot`], which owns the same DB handle.) +fn client_tool_id(db: &DbPool, client_id: &str) -> Option { + let conn = db.read().ok()?; + conn.query_row( + "SELECT tool_id FROM oauth_clients WHERE client_id = ?1", + params![client_id], + |row| row.get(0), + ) + .ok() + .flatten() +} + // ── Authorization ──────────────────────────────────────────────────────── #[derive(Deserialize)] @@ -404,11 +423,24 @@ struct AuthorizeParams { scope: Option, } -async fn authorize_page(headers: HeaderMap, Query(params): Query) -> Html { +async fn authorize_page( + State(oauth): State, + headers: HeaderMap, + Query(params): Query, +) -> Html { // Bind the CSRF token to the session the browser presents when loading this // page (sent on the top-level GET navigation under SameSite=Lax). The POST // approval must carry the same session for the token to validate. let csrf_token = generate_csrf_token(&session_credential(&headers)); + + // LIFIC-13: the approval screen asks which tool is connecting so the audit + // log can attribute requests to a per-tool bot. Options come from the same + // Connected Tools registry `lific connect` uses; a free-text field covers + // unrecognized tools. LIFIC-15: if this client is already remembered, + // pre-select that tool instead of re-asking on a reconnect. + let preset_id = client_tool_id(&oauth.db, ¶ms.client_id); + let tool_pick_list = tool_pick_list_html(preset_id.as_deref()); + Html(format!( r#" @@ -420,8 +452,10 @@ async fn authorize_page(headers: HeaderMap, Query(params): Query @@ -437,6 +471,7 @@ async fn authorize_page(headers: HeaderMap, Query(params): Query + {tool_pick_list} @@ -450,6 +485,7 @@ async fn authorize_page(headers: HeaderMap, Query(params): Query, csrf_token: Option, + /// LIFIC-13: which tool is connecting — a Connected Tools registry id, or + /// empty meaning `tool_custom` holds a free-text name. + tool: Option, + /// Free-text tool name when `tool` is unset (an unrecognized tool). + tool_custom: Option, } async fn authorize_approve( @@ -565,6 +606,22 @@ async fn authorize_approve( .into_response(); } + // LIFIC-13: pick which tool is connecting, then ensure (or reuse) its bot + // so the issued credential attributes to the tool, not the approving human. + // The bot inherits the human's permissions via authz's bot→owner resolution. + let bot_id = match resolve_approval_bot( + &oauth, + &form.tool, + &form.tool_custom, + approving_user_id, + Some(&form.client_id), + ) { + Ok(id) => id, + Err((status, msg)) => { + return (status, Html(msg)).into_response(); + } + }; + let code = uuid_v4(); let expires = chrono::Utc::now() + chrono::Duration::minutes(10); let scope = form.scope.as_deref().unwrap_or("mcp"); @@ -584,7 +641,7 @@ async fn authorize_approve( form.code_challenge_method.unwrap_or_else(|| "S256".into()), expires.to_rfc3339(), scope, - approving_user_id, + bot_id, ], ) { tracing::error!(error = %e, "failed to store OAuth authorization code"); @@ -635,6 +692,191 @@ fn generate_user_code() -> String { out } +/// Tool ids that a human can't claim as a free-text tool — they'd collide with +/// real internal identities (`admin`, `system`) or are meaningless. +const RESERVED_TOOL_IDS: &[&str] = &["admin", "system"]; + +/// Resolve the connecting tool from the approval form's choice, returning its +/// `(tool_id, display_name)`. +/// +/// LIFIC-13: known tools come from the Connected Tools registry +/// (`cli::connect::clients::all_clients`) so the approval pick-list and the +/// bot's display name match what `lific connect` writes. Unknown tools fall to +/// free text: lowercased, non-alphanumerics collapsed to `-`, then rejected if +/// they hit a reserved id. +fn resolve_tool(raw: &str) -> Result<(String, String), LificError> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(LificError::BadRequest("tool cannot be empty".into())); + } + // Known registry client: keep its canonical display name. + if let Some(client) = crate::cli::connect::clients::find_client(trimmed) { + return Ok((client.id.to_string(), client.display.to_string())); + } + + // Free text: sanitize down to a slug id, fall back to the humanized text. + let slug: String = trimmed + .to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + if slug.is_empty() { + return Err(LificError::BadRequest("tool id cannot be empty".into())); + } + if RESERVED_TOOL_IDS.contains(&slug.as_str()) { + return Err(LificError::BadRequest(format!( + "tool id '{slug}' is reserved" + ))); + } + Ok((slug.clone(), trimmed.to_string())) +} + +/// HTML `", + html_escape(c.id), + html_escape(c.display) + )); + } + let custom_option = if is_custom { " selected" } else { "" }; + options.push_str(&format!( + "", + CUSTOM_TOOL_OPTION + )); + + // The placeholder is only the selected placeholder when there's no remembered + // tool — the remembered option (or Custom) must win, not the placeholder. + let placeholder_sel = if preset_id.is_some() { "" } else { " selected" }; + let (custom_visible, custom_value) = if is_custom { + ("block", html_escape(preset_id.unwrap_or_default())) + } else { + ("none", String::new()) + }; + + format!( + " + +
+ + +
+ ", + custom_option_value = CUSTOM_TOOL_OPTION, + ) +} + +/// The shared LIFIC-13 tool-resolution + bot-mint step used by both the +/// auth-code and device approval doors. +/// +/// Resolves which tool is connecting from the form's `(tool, tool_custom)` +/// pair, validates it, then mints (or reuses) the per-tool bot owned by the +/// approving human. Returns the bot's user id on success, or a small +/// `(StatusCode, message)` the caller renders as its error page (missing tool, +/// unsanitizable/reserved id, no resolvable owner, or DB failure). +/// +/// When `client_id` is `Some`, the resolved `tool_id` is remembered on that +/// client (LIFIC-15) so a reconnect pre-fills the pick-list instead of +/// re-asking. The device flow passes `None` — it has no persistent client. +fn resolve_approval_bot( + oauth: &OAuthState, + tool: &Option, + tool_custom: &Option, + approving_user_id: Option, + client_id: Option<&str>, +) -> Result { + let tool_text = match (tool, tool_custom) { + // A specific known tool chosen from the pick-list. + (Some(id), _) + if !id.trim().is_empty() && id.trim() != CUSTOM_TOOL_OPTION => + { + id.clone() + } + // "Custom tool…" chosen — the free-text name is required. + (Some(id), Some(name)) + if id.trim() == CUSTOM_TOOL_OPTION && !name.trim().is_empty() => + { + name.clone() + } + _ => return Err((StatusCode::BAD_REQUEST, "Pick which tool is connecting".into())), + }; + let (tool_id, display_name) = match resolve_tool(&tool_text) { + Ok(v) => v, + Err(e) => return Err((StatusCode::BAD_REQUEST, e.to_string())), + }; + let Some(owner_id) = approving_user_id else { + return Err(( + StatusCode::BAD_REQUEST, + "No operator to attribute to — sign in as a human first".into(), + )); + }; + let conn = match oauth.db.write() { + Ok(c) => c, + Err(_) => return Err((StatusCode::INTERNAL_SERVER_ERROR, "database error".into())), + }; + // LIFIC-15: remember the tool on the client (same conn, best-effort). + if let Some(client_id) = client_id + && let Err(e) = conn.execute( + "UPDATE oauth_clients SET tool_id = ?1 WHERE client_id = ?2", + params![tool_id, client_id], + ) + { + tracing::error!(error = %e, client_id, "failed to remember client tool"); + } + match crate::db::queries::users::ensure_bot(&conn, owner_id, &tool_id, &display_name) { + Ok(bot) => Ok(bot.id), + Err(e) => { + tracing::error!(error = %e, "failed to mint OAuth tool bot"); + Err((StatusCode::INTERNAL_SERVER_ERROR, "database error".into())) + } + } +} + /// Normalize a user code the human may have typed with lowercase letters, /// spaces, or a missing dash: uppercase, strip everything but the alphabet, /// then re-insert the dash after 4 chars. `bcdf ghjk` and `bcdfghjk` both @@ -804,6 +1046,10 @@ async fn device_page(headers: HeaderMap, Query(q): Query) -> Ht .as_deref() .map(normalize_user_code) .unwrap_or_default(); + // LIFIC-13: same Connected Tools pick-list as the authorize screen. The + // device flow has no persistent client to key a remembered tool on (device + // codes are one-time handshakes), so it always asks. + let tool_pick_list = tool_pick_list_html(None); Html(format!( r#" @@ -815,7 +1061,8 @@ async fn device_page(headers: HeaderMap, Query(q): Query) -> Ht h1 {{ font-size: 1.4em; margin-bottom: 0.5em; }} p {{ color: #888; line-height: 1.5; }} label {{ display: block; margin-top: 1.5em; color: #aaa; font-size: 0.9em; }} - input[type=text] {{ width: 100%; box-sizing: border-box; margin-top: 0.4em; padding: 12px; border-radius: 6px; border: 1px solid #333; background: #111; color: #fff; font-size: 1.2em; letter-spacing: 0.15em; text-align: center; text-transform: uppercase; }} + input[type=text], select {{ width: 100%%; box-sizing: border-box; margin-top: 0.4em; padding: 12px; border-radius: 6px; border: 1px solid #333; background: #111; color: #fff; }} + input[type=text] {{ font-size: 1.2em; letter-spacing: 0.15em; text-align: center; text-transform: uppercase; }} .buttons {{ display: flex; gap: 12px; margin-top: 2em; }} button {{ flex: 1; color: white; border: none; padding: 12px 24px; border-radius: 6px; font-size: 1em; cursor: pointer; }} button.approve {{ background: #2563eb; }} @@ -830,6 +1077,7 @@ async fn device_page(headers: HeaderMap, Query(q): Query) -> Ht
+ {tool_pick_list}
@@ -840,6 +1088,7 @@ async fn device_page(headers: HeaderMap, Query(q): Query) -> Ht "#, user_code = html_escape(&prefill), csrf_token = html_escape(&csrf_token), + tool_pick_list = tool_pick_list, )) } @@ -848,6 +1097,11 @@ struct DeviceApproveForm { user_code: String, decision: Option, csrf_token: Option, + /// LIFIC-13: which tool is connecting — a registry id, or empty meaning + /// `tool_custom` holds a free-text name. + tool: Option, + /// Free-text tool name when `tool` is unset. + tool_custom: Option, } /// `POST /oauth/device` — validate CSRF + session, then mark the device code @@ -909,19 +1163,34 @@ async fn device_approve( let normalized = normalize_user_code(&form.user_code); let deny = form.decision.as_deref() == Some("deny"); + // Originally pending, unexpired codes only get acted on below. Defer the + // write-lock acquisition until after LIFIC-13 bot resolution, because + // resolve_approval_bot also takes a write lock — acquiring it here first + // would self-deadlock the pool. + let new_status = if deny { "denied" } else { "approved" }; + + // LIFIC-13: on approval, pick which tool is connecting and mint (or reuse) + // its bot, binding the device code to the bot so the exchanged token + // attributes to the tool. Deny needs no tool resolution. + let target_user_id: Option = if deny { + approving_user_id + } else { + match resolve_approval_bot(&oauth, &form.tool, &form.tool_custom, approving_user_id, None) { + Ok(id) => Some(id), + Err((status, msg)) => return (status, Html(msg)).into_response(), + } + }; + let conn = match oauth.db.write() { Ok(c) => c, Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response(), }; - - // Only pending, unexpired codes can be acted on. - let new_status = if deny { "denied" } else { "approved" }; let updated = conn .execute( "UPDATE oauth_device_codes SET status = ?1, user_id = ?2 WHERE user_code = ?3 AND status = 'pending' AND expires_at > datetime('now')", - params![new_status, approving_user_id, normalized], + params![new_status, target_user_id, normalized], ) .unwrap_or(0); @@ -1567,7 +1836,7 @@ mod tests { fn authorize_body(client_id: &str, redirect_uri: &str, binding: &str) -> String { let csrf = generate_csrf_token(binding); format!( - "client_id={}&redirect_uri={}&response_type=code&code_challenge=abc&code_challenge_method=S256&scope=mcp&csrf_token={}", + "client_id={}&redirect_uri={}&response_type=code&code_challenge=abc&code_challenge_method=S256&scope=mcp&csrf_token={}&tool=claude-code", client_id, urlencoding::encode(redirect_uri), urlencoding::encode(&csrf), @@ -2381,7 +2650,7 @@ mod tests { // CSRF bound to the session presented on the approval (cookie below). let csrf = generate_csrf_token(&session_token); let body = format!( - "client_id={}&redirect_uri={}&response_type=code&code_challenge={}&code_challenge_method=S256&scope=mcp&csrf_token={}", + "client_id={}&redirect_uri={}&response_type=code&code_challenge={}&code_challenge_method=S256&scope=mcp&csrf_token={}&tool=claude-code", client_id, urlencoding::encode("http://localhost/callback"), urlencoding::encode(&challenge), @@ -2423,7 +2692,18 @@ mod tests { .unwrap() .to_string(); - // The authorization code carries the approver's id. + // LIFIC-13: the issued credential binds to the per-tool BOT, not the + // approving human, so the audit log distinguishes which tool acted. + let (bot_id, bot_username): (i64, String) = { + let conn = db.read().unwrap(); + conn.query_row( + "SELECT id, username FROM users WHERE username = 'claude-code-oauthtest'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap() + }; + assert!(!bot_username.is_empty()); { let conn = db.read().unwrap(); let code_user: Option = conn @@ -2433,7 +2713,11 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert_eq!(code_user, Some(user_id), "code should bind the approver"); + assert_eq!( + code_user, + Some(bot_id), + "code should bind the tool bot, not the approver" + ); } // Exchange the code; the issued token must carry the same identity. @@ -2465,8 +2749,286 @@ mod tests { let val: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); let access_token = val["access_token"].as_str().unwrap(); - // The middleware will resolve this token to the approving user. - assert_eq!(oauth_token_user_id(&db, access_token), Some(user_id)); + // The middleware resolves this token to the tool bot, not the human. + assert_eq!(oauth_token_user_id(&db, access_token), Some(bot_id)); + assert_ne!(bot_id, user_id, "bot must differ from the approving human"); + } + + #[tokio::test] + async fn reapproval_reuses_the_same_tool_bot() { + let (app, db) = test_oauth_app(); + let session_token = create_test_session(&db); // user "oauthtest" + let client_id = register_client_helper(&app, "http://localhost/callback").await; + + let approve = |app: Router, csrf: &str| { + let body = format!( + "client_id={}&redirect_uri={}&response_type=code&code_challenge=abc&code_challenge_method=S256&scope=mcp&csrf_token={}&tool=opencode", + client_id, + urlencoding::encode("http://localhost/callback"), + urlencoding::encode(csrf), + ); + app.oneshot( + Request::builder() + .method("POST") + .uri("/oauth/authorize") + .header("content-type", "application/x-www-form-urlencoded") + .header("cookie", format!("lific_token={session_token}")) + .body(axum::body::Body::from(body)) + .unwrap(), + ) + }; + + assert!(approve(app.clone(), &generate_csrf_token(&session_token)) + .await + .unwrap() + .status() + .is_redirection()); + let first_id: i64 = { + let conn = db.read().unwrap(); + conn.query_row( + "SELECT id FROM users WHERE username = 'opencode-oauthtest'", + [], + |r| r.get(0), + ) + .unwrap() + }; + // Re-approval of the same tool+owner must reuse the same bot. + assert!(approve(app.clone(), &generate_csrf_token(&session_token)) + .await + .unwrap() + .status() + .is_redirection()); + let second_id: i64 = { + let conn = db.read().unwrap(); + conn.query_row( + "SELECT id FROM users WHERE username = 'opencode-oauthtest'", + [], + |r| r.get(0), + ) + .unwrap() + }; + assert_eq!( + first_id, second_id, + "re-approval must reuse the same bot, not mint a duplicate" + ); + } + + // ── LIFIC-15: remember tool per client, pre-fill on reconnect ── + + #[tokio::test] + async fn approve_persists_remembered_tool_on_client() { + let (app, db) = test_oauth_app(); + let session_token = create_test_session(&db); + let client_id = register_client_helper(&app, "http://localhost/callback").await; + + let body = authorize_body(&client_id, "http://localhost/callback", &session_token); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/oauth/authorize") + .header("content-type", "application/x-www-form-urlencoded") + .header("cookie", format!("lific_token={session_token}")) + .body(axum::body::Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert!(resp.status().is_redirection()); + + // The approved tool choice is remembered on the registered client. + let tool_id: Option = { + let conn = db.read().unwrap(); + conn.query_row( + "SELECT tool_id FROM oauth_clients WHERE client_id = ?1", + params![client_id], + |r| r.get(0), + ) + .unwrap() + }; + assert_eq!(tool_id.as_deref(), Some("claude-code")); + } + + #[tokio::test] + async fn authorize_page_prefills_remembered_known_tool() { + let (app, db) = test_oauth_app(); + let client_id = register_client_helper(&app, "http://localhost/callback").await; + + // Remember the tool on the client directly (as an approval would). + { + let conn = db.write().unwrap(); + conn.execute( + "UPDATE oauth_clients SET tool_id = 'opencode' WHERE client_id = ?1", + params![client_id], + ) + .unwrap(); + } + + let resp = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/oauth/authorize?client_id={client_id}&redirect_uri={}&response_type=code", urlencoding::encode("http://localhost/callback"))) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let html = String::from_utf8_lossy(&bytes); + // Reconnect: the known tool is pre-selected (real browser behavior — + // the placeholder must NOT also carry selected, or it wins in tree order). + assert!( + html.contains("value=\"opencode\" selected"), + "known tool should be pre-selected, html={html}" + ); + assert!( + !html.contains("value=\"\" selected"), + "placeholder must not also be selected when a tool is remembered, html={html}" + ); + } + + #[tokio::test] + async fn authorize_page_prefills_remembered_custom_tool() { + let (app, db) = test_oauth_app(); + let client_id = register_client_helper(&app, "http://localhost/callback").await; + + // A free-text tool: stored tool_id is a slug not in the registry. + { + let conn = db.write().unwrap(); + conn.execute( + "UPDATE oauth_clients SET tool_id = 'my-editor' WHERE client_id = ?1", + params![client_id], + ) + .unwrap(); + } + + let resp = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/oauth/authorize?client_id={client_id}&redirect_uri={}&response_type=code", urlencoding::encode("http://localhost/callback"))) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let html = String::from_utf8_lossy(&bytes); + // Reconnect: the custom field is revealed and pre-filled with the slug. + assert!( + html.contains("display:block"), + "custom tool field should be revealed, html={html}" + ); + assert!( + html.contains("value=\"my-editor\""), + "custom field should prefill the remembered slug, html={html}" + ); + } + + #[tokio::test] + async fn authorize_requires_a_tool_choice() { + let (app, db) = test_oauth_app(); + let session_token = create_test_session(&db); + let client_id = register_client_helper(&app, "http://localhost/callback").await; + let csrf = generate_csrf_token(&session_token); + // No tool, no tool_custom → must be rejected, not silently attributed. + let body = format!( + "client_id={}&redirect_uri={}&response_type=code&code_challenge=abc&code_challenge_method=S256&scope=mcp&csrf_token={}", + client_id, + urlencoding::encode("http://localhost/callback"), + urlencoding::encode(&csrf), + ); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/oauth/authorize") + .header("content-type", "application/x-www-form-urlencoded") + .header("cookie", format!("lific_token={session_token}")) + .body(axum::body::Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn authorize_rejects_reserved_free_text_tool() { + let (app, db) = test_oauth_app(); + let session_token = create_test_session(&db); + let client_id = register_client_helper(&app, "http://localhost/callback").await; + let csrf = generate_csrf_token(&session_token); + let body = format!( + "client_id={}&redirect_uri={}&response_type=code&code_challenge=abc&code_challenge_method=S256&scope=mcp&csrf_token={}&tool=__custom__&tool_custom=admin", + client_id, + urlencoding::encode("http://localhost/callback"), + urlencoding::encode(&csrf), + ); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/oauth/authorize") + .header("content-type", "application/x-www-form-urlencoded") + .header("cookie", format!("lific_token={session_token}")) + .body(axum::body::Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn authorize_custom_tool_choice_mints_a_sanitized_bot() { + // Selecting "Custom tool…" reveals a free-text field; a real custom + // tool name sanitizes into the bot's username and mints it. + let (app, db) = test_oauth_app(); + let session_token = create_test_session(&db); + let client_id = register_client_helper(&app, "http://localhost/callback").await; + let csrf = generate_csrf_token(&session_token); + let body = format!( + "client_id={}&redirect_uri={}&response_type=code&code_challenge=abc&code_challenge_method=S256&scope=mcp&csrf_token={}&tool=__custom__&tool_custom=My Editor", + client_id, + urlencoding::encode("http://localhost/callback"), + urlencoding::encode(&csrf), + ); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/oauth/authorize") + .header("content-type", "application/x-www-form-urlencoded") + .header("cookie", format!("lific_token={session_token}")) + .body(axum::body::Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert!( + resp.status().is_redirection(), + "custom free-text tool should approve, got {}", + resp.status() + ); + let bot: (String, String) = { + let conn = db.read().unwrap(); + conn.query_row( + "SELECT username, display_name FROM users WHERE username = 'my-editor-oauthtest'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap() + }; + assert_eq!(bot.0, "my-editor-oauthtest"); + assert_eq!(bot.1, "My Editor"); } #[tokio::test] @@ -2671,7 +3233,7 @@ mod tests { // Approve via the verification page (signed-in session, CSRF-bound). let csrf = generate_csrf_token(&session_token); let approve_body = format!( - "user_code={}&decision=approve&csrf_token={}", + "user_code={}&decision=approve&csrf_token={}&tool=claude-code", urlencoding::encode(&user_code), urlencoding::encode(&csrf), ); @@ -2690,7 +3252,16 @@ mod tests { .unwrap(); assert_eq!(resp.status(), StatusCode::OK, "approval should succeed"); - // The device row now binds the approver. + // LIFIC-13: the device row binds the per-tool BOT, not the approver. + let bot_id: i64 = { + let conn = db.read().unwrap(); + conn.query_row( + "SELECT id FROM users WHERE username = 'claude-code-oauthtest'", + [], + |r| r.get(0), + ) + .unwrap() + }; { let conn = db.read().unwrap(); let (st, uid): (String, Option) = conn @@ -2701,16 +3272,17 @@ mod tests { ) .unwrap(); assert_eq!(st, "approved"); - assert_eq!(uid, Some(user_id)); + assert_eq!(uid, Some(bot_id)); } - // Next poll: approved → returns a token bound to the approver. + // Next poll: approved → returns a token bound to the tool bot. reset_last_poll(&db); let (status, body) = poll_device_token(&app, &device_code).await; assert_eq!(status, StatusCode::OK, "expected token, got {body}"); let access_token = body["access_token"].as_str().unwrap(); assert!(access_token.starts_with("lific_at_")); - assert_eq!(oauth_token_user_id(&db, access_token), Some(user_id)); + assert_eq!(oauth_token_user_id(&db, access_token), Some(bot_id)); + assert_ne!(bot_id, user_id, "bot must differ from the approving human"); // Single-use: a replay poll now fails (consumed → invalid_grant). reset_last_poll(&db); @@ -2924,4 +3496,39 @@ mod tests { } } } + + // ── resolve_tool (LIFIC-13) ──────────────────────────────── + + #[test] + fn resolve_tool_known_registry_id_keeps_display_name() { + // A pick from the Connected Tools registry maps to its display name. + let (id, display) = resolve_tool("claude-code").unwrap(); + assert_eq!(id, "claude-code"); + assert_eq!(display, "Claude Code"); + } + + #[test] + fn resolve_tool_unregistered_tool_is_sanitized() { + // Free text gets lowercased and stripped to the id, display falls back + // to the same humanized text. + let (id, display) = resolve_tool("My Editor").unwrap(); + assert_eq!(id, "my-editor"); + assert_eq!(display, "My Editor"); + } + + #[test] + fn resolve_tool_rejects_reserved_words() { + for reserved in ["admin", "system"] { + assert!( + resolve_tool(reserved).is_err(), + "{reserved} is a reserved tool id" + ); + } + } + + #[test] + fn resolve_tool_rejects_empty_or_only_symbols() { + assert!(resolve_tool("").is_err()); + assert!(resolve_tool(" ").is_err()); + } } diff --git a/src/realtime.rs b/src/realtime.rs index 5cdfac9b..4cc8d825 100644 --- a/src/realtime.rs +++ b/src/realtime.rs @@ -344,7 +344,11 @@ fn visible_projects_for( db: &crate::db::DbPool, auth_user: &crate::db::models::AuthUser, ) -> Option> { - crate::authz::visible_project_ids(db, &Some(auth_user.clone())) + let identity = crate::resolve_caller::ResolvedIdentity { + user: auth_user.clone(), + transport: crate::actor::Transport::Web, + }; + crate::authz::visible_project_ids(db, &Some(identity)) .ok() .flatten() } @@ -386,10 +390,16 @@ fn visible_to( } } RealtimeAudience::Event => match message.event.project_id() { - Some(project_id) => match crate::authz::can_view_project(db, auth_user, project_id) { - Ok(true) => EventVisibility::Visible, - Ok(false) | Err(_) => EventVisibility::Hidden, - }, + Some(project_id) => { + let identity = crate::resolve_caller::ResolvedIdentity { + user: auth_user.clone(), + transport: crate::actor::Transport::Web, + }; + match crate::authz::can_view_project(db, &identity, project_id) { + Ok(true) => EventVisibility::Visible, + Ok(false) | Err(_) => EventVisibility::Hidden, + } + } None => EventVisibility::Visible, }, } diff --git a/src/resolve_caller.rs b/src/resolve_caller.rs new file mode 100644 index 00000000..9df66b70 --- /dev/null +++ b/src/resolve_caller.rs @@ -0,0 +1,253 @@ +//! LIFIC-8: the single place that decides who the caller is. +//! +//! Produces a [`ResolvedIdentity`] — a resolved identity with a *real* user +//! and the transport they came in on. The defining property, stated in the +//! spec ([LIFIC-7](http://localhost:3456/LIFIC/issues/LIFIC-7)): **there is +//! always a user — no anonymous.** Whenever a credential resolves no specific +//! user (an unbound API key, a legacy unbound OAuth token, a credential-less +//! "auth off" request, or a stdio MCP session), [`resolve_caller`] falls back +//! to the first admin — the same `first_admin` decision that was previously +//! scattered across four call sites (auto-login, authless MCP, comment-create, +//! comment-edit). Consolidating it here is the expand step: the new identity +//! exists *alongside* the legacy `Option` and nothing breaks while +//! downstream tickets (LIFIC-10/11) migrate the gates onto it. +//! +//! `None` is returned only in the degenerate zero-user bootstrap case — no +//! credential resolved a user *and* no admin exists yet. LIFIC-9 eliminates +//! that case by minting a first admin at `lific init` time; until then the +//! callers map `None` to the same error they already raised. + +use rusqlite::Connection; + +use crate::actor::Transport; +use crate::db::models::AuthUser; +use crate::db::queries; +use crate::error::LificError; + +/// The resolved identity for the current caller. The `user` is always a real +/// user (never `Option`); `transport` records which door they came in on, so +/// the audit log and per-transport logic keep working without a separate +/// operator signal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedIdentity { + pub user: AuthUser, + pub transport: Transport, +} + +/// Resolve the caller's identity. +/// +/// `credential_user` is whoever the credential itself named — a session's +/// user, an OAuth/API-key binding, or `None` when the credential carried no +/// user (unbound key, legacy OAuth) or there was no credential at all ("auth +/// off"). When that is `None`, the first admin is the passwordless fallback, +/// consolidating the four historical `first_admin` call sites into one +/// decision. +/// +/// Returns `Ok(None)` only when no user can be resolved at all (no credential +/// user *and* no admin exists). Callers preserve their existing behavior by +/// mapping that `None` to the same error they raise today. +pub fn resolve_caller_conn( + conn: &Connection, + credential_user: Option, + transport: Transport, +) -> Result, LificError> { + let user = match credential_user { + Some(u) => u, + None => match queries::users::first_admin(conn)? { + Some(admin) => admin, + None => return Ok(None), + }, + }; + Ok(Some(ResolvedIdentity { user, transport })) +} + +/// Convenience wrapper that opens its own read connection. The auth +/// middleware uses this: it has a [`DbPool`](crate::db::DbPool) but no live +/// borrow at its success return points. The credential-user path is DB-free, +/// so only the fallback hits the database. +pub fn resolve_caller( + db: &crate::db::DbPool, + credential_user: Option, + transport: Transport, +) -> Result, LificError> { + // Fast path: a credential already named a user — no DB read needed. + if let Some(user) = credential_user { + return Ok(Some(ResolvedIdentity { user, transport })); + } + let conn = db.read()?; + resolve_caller_conn(&conn, None, transport) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::{self, queries}; + use crate::db::models::CreateUser; + + fn test_db() -> db::DbPool { + db::open_memory().expect("test db") + } + + fn seed_admin(conn: &Connection, username: &str) -> AuthUser { + let u = queries::users::create_user( + conn, + &CreateUser { + username: username.into(), + email: format!("{username}@local.test"), + password: "adminpass123".into(), + display_name: Some(format!("Admin {username}")), + is_admin: true, + is_bot: false, + }, + ) + .unwrap(); + AuthUser { + id: u.id, + username: u.username, + display_name: u.display_name, + is_admin: u.is_admin, + } + } + + fn seed_regular(conn: &Connection, username: &str) -> AuthUser { + let u = queries::users::create_user( + conn, + &CreateUser { + username: username.into(), + email: format!("{username}@local.test"), + password: "userpass123".into(), + display_name: None, + is_admin: false, + is_bot: false, + }, + ) + .unwrap(); + AuthUser { + id: u.id, + username: u.username, + display_name: u.display_name, + is_admin: u.is_admin, + } + } + + // ── credential-user path: pure, no DB, transport passes through ────── + + #[test] + fn credential_user_is_returned_unchanged_with_its_transport() { + let pool = test_db(); + let conn = pool.read().unwrap(); + let regular = seed_regular(&conn, "alice"); + + for transport in [Transport::Web, Transport::Mcp, Transport::Api, Transport::Cli] { + let id = resolve_caller_conn(&conn, Some(regular.clone()), transport) + .unwrap() + .expect("Some(credential) always resolves"); + assert_eq!(id.user, regular); + assert_eq!(id.transport, transport); + } + } + + // The credential-user path never touches the DB: it resolves even when no + // users exist at all (the user came in on the credential). + #[test] + fn credential_user_resolves_with_zero_users_in_db() { + let pool = test_db(); // no users seeded + let conn = pool.read().unwrap(); + let phantom = AuthUser { + id: 999, + username: "phantom".into(), + display_name: String::new(), + is_admin: false, + }; + let id = resolve_caller_conn(&conn, Some(phantom.clone()), Transport::Api) + .unwrap() + .expect("credential user resolves regardless of DB state"); + assert_eq!(id.user, phantom); + } + + // ── fallback path: first_admin when no credential user ──────────────── + + #[test] + fn none_credential_falls_back_to_first_admin() { + let pool = test_db(); + let conn = pool.write().unwrap(); + let admin = seed_admin(&conn, "admin"); + // A second admin created later must NOT win — first_admin is ordered + // by created_at, so the earliest admin is the stable fallback. + let later = seed_admin(&conn, "later"); + assert_ne!(admin.id, later.id); + drop(conn); + + let conn = pool.read().unwrap(); + let id = resolve_caller_conn(&conn, None, Transport::Mcp) + .unwrap() + .expect("first_admin fallback should resolve"); + assert_eq!(id.user, admin, "fallback must be the earliest admin"); + assert_eq!(id.transport, Transport::Mcp); + } + + // The fallback only considers admins; a non-admin user alone is not a + // fallback candidate, so None credential + no admin → None. + #[test] + fn none_credential_with_no_admin_returns_none() { + let pool = test_db(); + let conn = pool.write().unwrap(); + seed_regular(&conn, "onlyuser"); + drop(conn); + + let conn = pool.read().unwrap(); + assert!(resolve_caller_conn(&conn, None, Transport::Api) + .unwrap() + .is_none()); + } + + // Zero-user bootstrap: no credential and no users at all → None. This is + // the degenerate case LIFIC-9 eliminates by minting an admin at init. + #[test] + fn none_credential_zero_users_returns_none() { + let pool = test_db(); + let conn = pool.read().unwrap(); + assert!(resolve_caller_conn(&conn, None, Transport::System) + .unwrap() + .is_none()); + } + + // ── DbPool wrapper mirrors the conn core ────────────────────────────── + + #[test] + fn dbpool_overload_and_conn_core_agree_on_first_admin_fallback() { + let pool = test_db(); + { + let conn = pool.write().unwrap(); + seed_admin(&conn, "admin"); + } + let via_conn = { + let conn = pool.read().unwrap(); + resolve_caller_conn(&conn, None, Transport::Api) + .unwrap() + .expect("conn fallback resolves") + }; + let via_pool = resolve_caller(&pool, None, Transport::Api) + .unwrap() + .expect("pool wrapper fallback resolves"); + assert_eq!(via_conn, via_pool); + } + + #[test] + fn credential_user_resolves_without_opening_a_db_connection() { + // No users at all, yet a credential user resolves — proves the pool + // wrapper's fast path never opens a read connection. + let pool = test_db(); + let user = AuthUser { + id: 1, + username: "cred".into(), + display_name: String::new(), + is_admin: false, + }; + let id = resolve_caller(&pool, Some(user.clone()), Transport::Web) + .unwrap() + .expect("credential fast path"); + assert_eq!(id.user, user); + assert_eq!(id.transport, Transport::Web); + } +} diff --git a/web/src/App.svelte b/web/src/App.svelte index 390cd516..5c6eed17 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -73,13 +73,28 @@ let realtimeDisposed = false; onMount(async () => { - if (!hasSession()) { - const inst = await getInstance(); - if (inst.ok && inst.data.web_auto_login) { - const res = await autoLogin(); - if (res.ok) saveSession(res.data.token); + // Probe the instance once; its auto-login flag decides single-user mode. + const inst = await getInstance(); + + // LIF-215 follow-up: trust a stored session only if it still validates. + // `hasSession()` only checks localStorage — an expired token (server + // rejects with 401) would otherwise look valid, skip auto-login, and leave + // the user stuck with a dead session. Clear it so the flow below can self- + // heal in single-user mode (or fall through to /login otherwise). + if (hasSession()) { + const probe = await me(); + if (!probe.ok && probe.status === 401) { + clearSession(); } } + + // Single-user mode: no valid session → silently mint a fresh admin session + // (covers both a cold load with no token and a just-expired one). + if (!hasSession() && inst.ok && inst.data.web_auto_login) { + const res = await autoLogin(); + if (res.ok) saveSession(res.data.token); + } + bootstrapping = false; if (!realtimeDisposed) syncRealtimeSocket(); }); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2c3e7c3c..d59b6551 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -253,7 +253,7 @@ export interface Bot { display_name: string; owner_id: number | null; created_at: string; - has_active_key: boolean; + connected: boolean; } export interface CreateBotResponse { diff --git a/web/src/routes/Settings.svelte b/web/src/routes/Settings.svelte index fd1a24f5..a90525f5 100644 --- a/web/src/routes/Settings.svelte +++ b/web/src/routes/Settings.svelte @@ -276,7 +276,7 @@ function toolState(toolId: string): "connected" | "disconnected" | "none" { const bot = getToolBot(toolId); if (!bot) return "none"; - return bot.has_active_key ? "connected" : "disconnected"; + return bot.connected ? "connected" : "disconnected"; } // Open the modal and mint credentials in one step (no extra confirm).