diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 4706c0bae..b80b10af4 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -170,6 +170,8 @@ macro_rules! impl_openapi_schema { impl_openapi_schema!( AuthBootstrapUserRequest, AuthPublicConfig, + HostingSettings, + HostingAccess, AuthUserResponse, DeviceLoginApproveRequest, DeviceLoginApproveResponse, @@ -515,6 +517,28 @@ pub trait ServerApi { #[get("/api/auth/config", status = 200, error_status = 400)] async fn auth_config(&self) -> Result; + #[get("/api/auth/registration-policy", status = 200, error_status = 400)] + async fn auth_registration_policy(&self) -> Result; + + #[get("/api/hosting/access", status = 200, error_status = 403, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)] + async fn hosting_access( + &self, + #[extension] context: ServerRequestContext, + ) -> Result; + + #[get("/api/hosting/settings", status = 200, error_status = 403, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)] + async fn hosting_settings( + &self, + #[extension] context: ServerRequestContext, + ) -> Result; + + #[put("/api/hosting/settings", status = 200, error_status = 403, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)] + async fn hosting_settings_update( + &self, + #[extension] context: ServerRequestContext, + #[body] request: HostingSettings, + ) -> Result; + #[post( "/api/auth/bootstrap-user", status = 200, @@ -3846,6 +3870,22 @@ pub struct AuthPublicConfig { pub cookie_name: String, } +/// The only public registration policy. No administrator identities or host secrets are exposed. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct HostingSettings { + pub self_registration_enabled: bool, +} + +/// Hosting authority for the current authenticated human, not Workspace permissions. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct HostingAccess { + pub is_admin: bool, +} + /// Authentication method that established the current request actor. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] @@ -11395,6 +11435,8 @@ pub fn auth_api_typescript() -> String { let config = ts_rs::Config::default(); let declarations = [ AuthPublicConfig::decl(&config), + HostingSettings::decl(&config), + HostingAccess::decl(&config), ActorAuthMethod::decl(&config), AuthenticatedUser::decl(&config), RequestActor::decl(&config), @@ -14380,6 +14422,7 @@ mod openapi_artifact_tests { const PUBLIC_UNAUTHENTICATED: &[&str] = &[ "health", "auth_config", + "auth_registration_policy", "auth_bootstrap_user", "auth_passkey_registration_options", "auth_passkey_registration_complete", diff --git a/crates/workspace-server/src/assignment_tracking_tests.rs b/crates/workspace-server/src/assignment_tracking_tests.rs index 15ce6f520..a280700e9 100644 --- a/crates/workspace-server/src/assignment_tracking_tests.rs +++ b/crates/workspace-server/src/assignment_tracking_tests.rs @@ -472,7 +472,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip ), (96,"Backend Job common notification routing".into()), (97, "MR notifications and authenticated implementation results".into()), - (98, "Assignment-independent implementation reports".into()) + (98, "Assignment-independent implementation reports".into()), + (99, "Explicit hosting administrators and self-registration policy".into()) ] ); for (table, rows) in retained.iter().zip(original.iter()) { diff --git a/crates/workspace-server/src/implementation_result_tests.rs b/crates/workspace-server/src/implementation_result_tests.rs index f362c80a8..8c3210c84 100644 --- a/crates/workspace-server/src/implementation_result_tests.rs +++ b/crates/workspace-server/src/implementation_result_tests.rs @@ -54,7 +54,7 @@ async fn implementation_result_without_any_assignment_is_authenticated_and_never let before = store.with_conn(|conn| { // Retention holds are not reporting permissions. conn.execute("UPDATE worker_registry SET retention_state='pinned' WHERE workspace_id='w' AND worker_id='replacement'", [])?; - assert_eq!(current_schema_version(conn)?, 98); + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); assert_eq!(conn.query_row("SELECT count(*) FROM ticket_worker_assignments", [], |r| r.get::<_, i64>(0))?, 0); assert_eq!(conn.query_row("SELECT count(*) FROM pragma_table_info('ticket_implementation_results') WHERE name IN ('assignment_id','orchestrator_assignment_id')", [], |r| r.get::<_, i64>(0))?, 0); assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name='implementation_delegation_capture'", [], |r| r.get::<_, i64>(0))?, 0); @@ -372,7 +372,7 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no let (dir, store) = tracking_store_fixture().await; tracking_store_assign(&store, "delegation", "assign"); let worker = routing_worker_assign(&store, "implementation").worker; - // Reconstruct schema97 from a fresh schema98 fixture, including its historical capture. + // Reconstruct schema97 from a fresh current-schema fixture, including its historical capture. store.with_conn(|conn| { conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; @@ -381,6 +381,8 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations; + DROP TABLE hosting_administrators; DROP TABLE hosting_settings; + UPDATE __yoi_schema_migrations SET version=98 WHERE version=99; UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?; migrate_business_notifications_v96_to_v97(conn)?; assert_eq!(current_schema_version(conn)?, 97); @@ -509,6 +511,8 @@ async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknow // Reconstruct the prior composition before its new business capture existed. conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture; DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations; + DROP TABLE hosting_administrators; DROP TABLE hosting_settings; + UPDATE __yoi_schema_migrations SET version=98 WHERE version=99; UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?; conn.execute_batch("INSERT INTO merge_requests VALUES('w','historical-mr','main','open','refs/heads/work','refs/heads/main','3','3'); INSERT INTO merge_request_ticket_relations VALUES('w','historical-mr','tracking','implements','3'); @@ -663,7 +667,9 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() { if historical { conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture; DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations; - UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?; + DROP TABLE hosting_administrators; DROP TABLE hosting_settings; + UPDATE __yoi_schema_migrations SET version=98 WHERE version=99; + UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?; } conn.execute_batch("INSERT INTO merge_requests VALUES('w','actor-mr','main','open','work','main','3','3'); INSERT INTO merge_request_ticket_relations VALUES('w','actor-mr','tracking','implements','3');")?; diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index be5790c66..890395f91 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -12,6 +12,15 @@ DROP TABLE typed_ticket_events; DROP TABLE typed_tickets; DROP TABLE workspace_resource_key_counters; DROP TABLE workspace_resource_keys; +CREATE TABLE hosting_administrators ( + user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, + created_at TEXT NOT NULL +); +CREATE TABLE hosting_settings ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + self_registration_enabled INTEGER NOT NULL DEFAULT 0 CHECK (self_registration_enabled IN (0, 1)) +); +INSERT INTO hosting_settings(singleton, self_registration_enabled) VALUES(1, 0); CREATE TABLE accounts ( account_id TEXT PRIMARY KEY, kind TEXT NOT NULL CHECK (kind IN ('user', 'organization')), diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index f68f9f639..512332527 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -22,6 +22,7 @@ enum Command { Serve(ServeOptions), Migrate(MigrateOptions), BootstrapAuth(BootstrapAuthOptions), + HostingAdmin(HostingAdminOptions), Skills(SkillsCommand), Help, } @@ -46,6 +47,13 @@ struct BootstrapAuthOptions { label: Option, } +#[derive(Debug)] +struct HostingAdminOptions { + handle: String, + create_user: bool, + database: Option, +} + #[derive(Debug)] struct SkillWorkspaceOptions { workspace_id: String, @@ -86,6 +94,7 @@ async fn run() -> Result<(), Box> { Command::Serve(options) => run_serve(options).await, Command::Migrate(options) => run_migrate(options), Command::BootstrapAuth(options) => run_bootstrap_auth(options), + Command::HostingAdmin(options) => run_hosting_admin(options), Command::Skills(command) => run_skills(command), Command::Help => Ok(()), } @@ -100,6 +109,13 @@ fn parse_command(args: &[String]) -> Result { match command.as_str() { "migrate" => parse_migrate_options(rest).map(Command::Migrate), "bootstrap-auth" => parse_bootstrap_auth_options(rest).map(Command::BootstrapAuth), + "hosting-admin" => { + if rest.iter().any(|arg| arg == "--help" || arg == "-h") { + print_hosting_admin_help(); + return Ok(Command::Help); + } + parse_hosting_admin_options(rest).map(Command::HostingAdmin) + } "skills" => parse_skills_command(rest), "serve" => { if rest.iter().any(|arg| arg == "--help" || arg == "-h") { @@ -113,7 +129,7 @@ fn parse_command(args: &[String]) -> Result { Ok(Command::Help) } other => Err(CliError(format!( - "unknown command `{other}`; expected `bootstrap-auth`, `migrate`, `skills`, or `serve`" + "unknown command `{other}`; expected `hosting-admin`, `bootstrap-auth`, `migrate`, `skills`, or `serve`" ))), } } @@ -224,6 +240,85 @@ fn run_migrate(options: MigrateOptions) -> Result<(), Box Ok(()) } +fn run_hosting_admin(options: HostingAdminOptions) -> Result<(), Box> { + let database_path = options + .database + .unwrap_or_else(ServerConfig::default_server_database_path); + if let Some(parent) = database_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent)?; + } + let store = SqliteWorkspaceStore::open(&database_path)?; + let setup = store.setup_hosting_admin(&options.handle, options.create_user)?; + let mut output = serde_json::json!({ + "user": { + "user_id": setup.user.user_id, + "account_id": setup.user.account_id, + "handle": setup.user.handle, + "display_name": setup.user.display_name, + }, + "hosting_admin": true, + }); + if let Some(bearer) = setup.initial_bearer { + output["access_token"] = bearer.access_token.into(); + output["expires_at"] = bearer.expires_at.into(); + output["token_type"] = "bearer".into(); + } + // Explicit trusted host setup is the only place this secret is printed. + println!("{}", serde_json::to_string(&output)?); + Ok(()) +} + +fn parse_hosting_admin_options(args: &[String]) -> Result { + let mut handle = None; + let mut database = None; + let mut create_user = false; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == "--create-user" { + if create_user { + return Err(CliError("--create-user must be provided only once".into())); + } + create_user = true; + index += 1; + continue; + } + let (name, inline) = arg + .split_once('=') + .map_or((arg.as_str(), None), |(name, value)| (name, Some(value))); + let target = match name { + "--handle" => &mut handle, + "--database" => &mut database, + other => return Err(CliError(format!("unknown hosting-admin option {other}"))), + }; + let value = match inline { + Some(value) => value.to_string(), + None => { + index += 1; + args.get(index) + .filter(|value| !value.starts_with("--")) + .ok_or_else(|| CliError(format!("{name} requires a value")))? + .clone() + } + }; + if value.is_empty() { + return Err(CliError(format!("{name} requires a value"))); + } + if target.replace(value).is_some() { + return Err(CliError(format!("{name} must be provided only once"))); + } + index += 1; + } + Ok(HostingAdminOptions { + handle: handle.ok_or_else(|| CliError("hosting-admin requires --handle".into()))?, + create_user, + database: database.map(PathBuf::from), + }) +} + fn run_bootstrap_auth(options: BootstrapAuthOptions) -> Result<(), Box> { let database_path = ServerConfig::default_server_database_path(); if let Some(parent) = database_path.parent() { @@ -643,7 +738,13 @@ fn parse_listen(value: &str) -> Result { fn print_help() { println!( - "yoi-server\n\nUsage:\n yoi-server bootstrap-auth --handle [--display-name ] [--label