feat: explicit hosting administrators and persistent self-registration policy
This commit is contained in:
@@ -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()) {
|
||||
|
||||
@@ -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');")?;
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HostingAdminOptions {
|
||||
handle: String,
|
||||
create_user: bool,
|
||||
database: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SkillWorkspaceOptions {
|
||||
workspace_id: String,
|
||||
@@ -86,6 +94,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<Command, CliError> {
|
||||
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<Command, CliError> {
|
||||
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<dyn std::error::Error>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_hosting_admin(options: HostingAdminOptions) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<HostingAdminOptions, CliError> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<SocketAddr, CliError> {
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"yoi-server\n\nUsage:\n yoi-server bootstrap-auth --handle <HANDLE> [--display-name <NAME>] [--label <LABEL>]\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||
"yoi-server\n\nUsage:\n yoi-server hosting-admin --handle <HANDLE> [--create-user] [--database <PATH>]\n yoi-server bootstrap-auth --handle <HANDLE> [--display-name <NAME>] [--label <LABEL>]\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||
);
|
||||
}
|
||||
|
||||
fn print_hosting_admin_help() {
|
||||
println!(
|
||||
"yoi-server hosting-admin\n\nUsage:\n yoi-server hosting-admin --handle <HANDLE> [--create-user] [--database <PATH>]\n\nDescription:\n Trusted host-only explicit administrator setup. By default selects an existing known user and grants hosting authority without issuing credentials. --create-user requires a new handle and atomically creates the user, grant and initial bearer (15-minute expiry), printed once to private stdout for passkey addition. Open /hosting/setup on this Server's trusted HTTPS origin (or localhost), enter the token, add a passkey, then explicitly sign in with that key before opening /hosting/settings. Repeating creation fails, never refreshing credentials. Neither mode enables public self-registration. Existing Workspace permissions are unchanged.\n\nOptions:\n --handle <HANDLE> Known user handle, or new handle with --create-user\n --create-user Explicitly create a new user and print its initial bearer\n --database <PATH> Server DB path (default: canonical Yoi server DB)\n -h, --help Print help"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -702,6 +803,51 @@ mod tests {
|
||||
assert_eq!(error.to_string(), "bootstrap-auth requires --handle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosting_admin_cli_requires_explicit_creation_and_selects_database() {
|
||||
let args = |values: &[&str]| {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| value.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let Command::HostingAdmin(existing) =
|
||||
parse_command(&args(&["hosting-admin", "--handle", "known-user"])).unwrap()
|
||||
else {
|
||||
panic!("expected hosting admin")
|
||||
};
|
||||
assert!(!existing.create_user);
|
||||
assert!(existing.database.is_none());
|
||||
let Command::HostingAdmin(new) = parse_command(&args(&[
|
||||
"hosting-admin",
|
||||
"--handle=new-admin",
|
||||
"--create-user",
|
||||
"--database=/tmp/isolated.db",
|
||||
]))
|
||||
.unwrap() else {
|
||||
panic!("expected hosting admin")
|
||||
};
|
||||
assert!(new.create_user);
|
||||
assert_eq!(new.handle, "new-admin");
|
||||
assert_eq!(new.database, Some(PathBuf::from("/tmp/isolated.db")));
|
||||
for invalid in [
|
||||
vec![],
|
||||
vec!["--create-user"],
|
||||
vec!["--handle"],
|
||||
vec!["--handle="],
|
||||
vec!["--handle", "a", "--handle=b"],
|
||||
vec!["--handle", "a", "--database"],
|
||||
vec!["--handle", "a", "--database=first", "--database=second"],
|
||||
vec!["--handle", "a", "--create-user", "--create-user"],
|
||||
vec!["--handle", "a", "--token", "secret"],
|
||||
] {
|
||||
assert!(
|
||||
parse_hosting_admin_options(&args(&invalid)).is_err(),
|
||||
"{invalid:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_migrate_uses_the_shared_schema_path() {
|
||||
let command = parse_command(&[
|
||||
@@ -832,7 +978,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"unknown command `{command}`; expected `bootstrap-auth`, `migrate`, `skills`, or `serve`"
|
||||
"unknown command `{command}`; expected `hosting-admin`, `bootstrap-auth`, `migrate`, `skills`, or `serve`"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3131,6 +3131,20 @@ async fn authorize_scoped_workspace_request(
|
||||
));
|
||||
}
|
||||
}
|
||||
if mutating || request.headers().contains_key(axum::http::header::UPGRADE) {
|
||||
let workspace = api
|
||||
.store
|
||||
.get_workspace(workspace_id)
|
||||
.await
|
||||
.map_err(server_error_response)?;
|
||||
if !workspace.is_some_and(|workspace| workspace.owner_account_id == actor.account_id) {
|
||||
return Err(repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::FORBIDDEN,
|
||||
"Workspace mutations and interactive control require the owner account",
|
||||
));
|
||||
}
|
||||
}
|
||||
request.extensions_mut().insert(actor);
|
||||
require_active_scoped_workspace(api, workspace_id, &request_path).await
|
||||
}
|
||||
@@ -3292,8 +3306,23 @@ async fn authorize_workspace_api_request(
|
||||
);
|
||||
}
|
||||
}
|
||||
if !public_server_api
|
||||
&& (mutating || request.headers().contains_key(axum::http::header::UPGRADE))
|
||||
{
|
||||
let workspace = match api.store.get_workspace(&workspace_id).await {
|
||||
Ok(workspace) => workspace,
|
||||
Err(error) => return server_error_response(error),
|
||||
};
|
||||
if !workspace.is_some_and(|workspace| workspace.owner_account_id == actor.account_id) {
|
||||
return repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::FORBIDDEN,
|
||||
"Workspace mutations and interactive control require the owner account",
|
||||
);
|
||||
}
|
||||
}
|
||||
request.extensions_mut().insert(actor);
|
||||
if mutating {
|
||||
if mutating && !public_server_api {
|
||||
let workspace = match api.store.get_workspace(&workspace_id).await {
|
||||
Ok(workspace) => workspace,
|
||||
Err(error) => return server_error_response(error),
|
||||
@@ -3358,6 +3387,11 @@ async fn dispatch_workspace_request(
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let path = request.uri().path().to_owned();
|
||||
// Hosting setup/settings belong to the Server, including zero or multiple
|
||||
// Workspaces. Serving their public shell does not grant their API authority.
|
||||
if path.starts_with("/hosting/") {
|
||||
return serve_server_static_shell(&api, &path).await;
|
||||
}
|
||||
let workspace_id = scoped_workspace_id(&path);
|
||||
// Admission is immediate, shared, and cancellation-safe. No resource or
|
||||
// business-operation lock may precede proof validation. The lease covers
|
||||
@@ -3464,6 +3498,7 @@ async fn dispatch_admitted_workspace_request(
|
||||
fn is_server_global_forward(path: &str) -> bool {
|
||||
path == "/api/auth"
|
||||
|| path.starts_with("/api/auth/")
|
||||
|| path.starts_with("/api/hosting/")
|
||||
|| path == "/health"
|
||||
|| path == "/"
|
||||
|| path.starts_with("/_app/")
|
||||
@@ -6700,6 +6735,13 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
|
||||
))
|
||||
.layer(middleware::from_fn(attach_server_origin_context));
|
||||
let actor_context = Router::new()
|
||||
.merge(server_api::server_api_axum::hosting_access(service.clone()))
|
||||
.merge(server_api::server_api_axum::hosting_settings(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::hosting_settings_update(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::auth_passkey_addition_options(
|
||||
service.clone(),
|
||||
))
|
||||
@@ -6716,6 +6758,9 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
|
||||
));
|
||||
Router::new()
|
||||
.merge(server_api::server_api_axum::auth_config(service.clone()))
|
||||
.merge(server_api::server_api_axum::auth_registration_policy(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::auth_bootstrap_user(
|
||||
service.clone(),
|
||||
))
|
||||
@@ -6730,6 +6775,16 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
|
||||
.merge(server_api::server_api_axum::auth_device_login_poll(service))
|
||||
.merge(origin_context)
|
||||
.merge(actor_context)
|
||||
.layer(middleware::map_response(
|
||||
|mut response: Response| async move {
|
||||
// Current role/policy and authentication responses must not be cached
|
||||
// across credential revocation or policy changes.
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn generated_workspace_catalog_contract_router(service: ServerApiContractService) -> Router {
|
||||
@@ -7611,6 +7666,78 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
Ok(auth_public_config(&self.auth_api().config))
|
||||
}
|
||||
|
||||
async fn auth_registration_policy(
|
||||
&self,
|
||||
) -> std::result::Result<server_api::HostingSettings, server_api::RepositoryApiError> {
|
||||
let api = self.auth_api();
|
||||
api.store
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
api.store
|
||||
.hosting_self_registration_enabled()
|
||||
.map(|self_registration_enabled| server_api::HostingSettings {
|
||||
self_registration_enabled,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ApiError::from(error).into_repository_api_error())
|
||||
}
|
||||
|
||||
async fn hosting_access(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
) -> std::result::Result<server_api::HostingAccess, server_api::RepositoryApiError> {
|
||||
let api = self.auth_api();
|
||||
api.store
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
let actor = live_hosting_actor(&api, &context, false)?;
|
||||
Ok::<_, ApiError>(server_api::HostingAccess {
|
||||
is_admin: api.store.is_hosting_admin(&actor.user_id)?,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn hosting_settings(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
) -> std::result::Result<server_api::HostingSettings, server_api::RepositoryApiError> {
|
||||
let api = self.auth_api();
|
||||
api.store
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
let actor = live_hosting_actor(&api, &context, false)?;
|
||||
require_hosting_admin(&api, &actor)?;
|
||||
Ok::<_, ApiError>(server_api::HostingSettings {
|
||||
self_registration_enabled: api.store.hosting_self_registration_enabled()?,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn hosting_settings_update(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
request: server_api::HostingSettings,
|
||||
) -> std::result::Result<server_api::HostingSettings, server_api::RepositoryApiError> {
|
||||
let api = self.auth_api();
|
||||
api.store
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
let actor = live_hosting_actor(&api, &context, true)?;
|
||||
api.store.set_hosting_self_registration_enabled(
|
||||
&actor,
|
||||
request.self_registration_enabled,
|
||||
)?;
|
||||
Ok::<_, ApiError>(request)
|
||||
})
|
||||
.await
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn auth_bootstrap_user(
|
||||
&self,
|
||||
request: AuthBootstrapUserRequest,
|
||||
@@ -29374,6 +29501,7 @@ async fn post_passkey_registration_options(
|
||||
api.store
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
require_self_registration(&api)?;
|
||||
let user = allocate_new_user(&api, &request.handle, request.display_name.as_deref())?;
|
||||
start_passkey_registration(
|
||||
&api,
|
||||
@@ -29552,6 +29680,7 @@ fn complete_passkey_enrollment(
|
||||
(PasskeyRegistrationTarget::NewAccount { user }, None)
|
||||
if addition_headers.is_none() && challenge.user_id.is_none() =>
|
||||
{
|
||||
require_self_registration(api)?;
|
||||
user.clone()
|
||||
}
|
||||
(
|
||||
@@ -30122,6 +30251,51 @@ async fn resolve_actor(
|
||||
Ok(resolve_request_actor(api.store.clone(), headers, &cookie_name).await?)
|
||||
}
|
||||
|
||||
// Hosting endpoints re-resolve actual transport credentials, not actor-like headers,
|
||||
// JSON or a stale middleware snapshot. The store rechecks live admin authority in
|
||||
// the settings transaction; cookie writes also enforce Origin at this direct entry.
|
||||
fn live_hosting_actor(
|
||||
api: &ServerAuthApi,
|
||||
context: &server_api::ServerRequestContext,
|
||||
mutating: bool,
|
||||
) -> ApiResult<RequestActor> {
|
||||
let headers = contract_request_headers(context)
|
||||
.map_err(|error| Error::WorkspacePermissionDenied(error.message))?;
|
||||
let auth = auth_public_config(&api.config);
|
||||
let actor = crate::auth::resolve_request_actor_sync(
|
||||
api.store.as_ref(),
|
||||
crate::auth::parse_bearer(&headers),
|
||||
parse_cookie(&headers, &auth.cookie_name),
|
||||
)?
|
||||
.ok_or_else(|| auth_error("auth_required", "current authentication required"))?;
|
||||
if mutating
|
||||
&& actor.auth_method == ActorAuthMethod::BrowserSession
|
||||
&& request_origin(&headers).as_deref() != Some(auth.origin.as_str())
|
||||
{
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"hosting settings require configured Browser origin".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(actor)
|
||||
}
|
||||
|
||||
fn require_hosting_admin(api: &ServerAuthApi, actor: &RequestActor) -> ApiResult<()> {
|
||||
if !api.store.is_hosting_admin(&actor.user_id)? {
|
||||
return Err(
|
||||
Error::WorkspacePermissionDenied("hosting administrator required".into()).into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_self_registration(api: &ServerAuthApi) -> ApiResult<()> {
|
||||
if !api.store.hosting_self_registration_enabled()? {
|
||||
return Err(Error::WorkspacePermissionDenied("self_registration_disabled".into()).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn require_actor(api: &ServerAuthApi, headers: &HeaderMap) -> ApiResult<RequestActor> {
|
||||
resolve_actor(api, headers).await?.ok_or_else(|| {
|
||||
auth_error(
|
||||
@@ -40032,10 +40206,532 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Router and direct trait admission use real SQLite authority and credentials.
|
||||
/// Only Runtime execution is replaced by the shared isolated test_api fixture.
|
||||
/// These tests run in the ordinary workspace-server library suite.
|
||||
mod hosting_authorization_tests {
|
||||
use super::*;
|
||||
use api_macros::HttpError;
|
||||
use server_api::ServerApi;
|
||||
|
||||
async fn response(app: Router, request: Request<Body>) -> (StatusCode, HeaderMap, Value) {
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let body = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
|
||||
(status, headers, body)
|
||||
}
|
||||
|
||||
fn settings_request(token: Option<&str>, method: &str) -> Request<Body> {
|
||||
let mut request = Request::builder()
|
||||
.method(method)
|
||||
.uri("/api/hosting/settings");
|
||||
if let Some(token) = token {
|
||||
request = request.header("authorization", format!("Bearer {token}"));
|
||||
}
|
||||
request
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"self_registration_enabled":true}"#))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn context(
|
||||
token: Option<&str>,
|
||||
actor: Option<RequestActor>,
|
||||
) -> server_api::ServerRequestContext {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(token) = token {
|
||||
headers.insert("authorization", format!("Bearer {token}").parse().unwrap());
|
||||
}
|
||||
server_request_context(actor, None, None, None, &headers)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosting_shell_is_server_scoped_with_zero_one_or_multiple_workspaces() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = test_server_config(dir.path());
|
||||
let assets = dir.path().join("static");
|
||||
fs::create_dir_all(&assets).unwrap();
|
||||
fs::write(
|
||||
assets.join("index.html"),
|
||||
"<!doctype html><title>Hosting shell</title>",
|
||||
)
|
||||
.unwrap();
|
||||
config.static_assets_dir = Some(assets);
|
||||
let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap());
|
||||
let app =
|
||||
workspace_server_router(WorkspaceServerApi::new(config.clone(), store.clone()));
|
||||
for count in 0..=2 {
|
||||
if count > 0 {
|
||||
config.workspace_id = Uuid::now_v7().to_string();
|
||||
seed_test_registered_workspace(store.as_ref(), &config)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
for path in ["/hosting/settings", "/hosting/setup"] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK, "{count}: {path}");
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert!(String::from_utf8_lossy(&body).contains("Hosting shell"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_registration_policy_is_noncacheable_boolean_and_tracks_persisted_setting() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let app = build_router(api.clone());
|
||||
let public = || {
|
||||
Request::builder()
|
||||
.uri("/api/auth/registration-policy")
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
};
|
||||
let (status, headers, body) = response(app.clone(), public()).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body, json!({"self_registration_enabled":false}));
|
||||
assert_eq!(headers.get(CACHE_CONTROL).unwrap(), "no-store");
|
||||
let admin = api
|
||||
.config_store
|
||||
.setup_hosting_admin("policy-admin", true)
|
||||
.unwrap();
|
||||
let token = admin.initial_bearer.unwrap().access_token;
|
||||
let (status, _, body) =
|
||||
response(app.clone(), settings_request(Some(&token), "PUT")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body, json!({"self_registration_enabled":true}));
|
||||
let reopened = SqliteWorkspaceStore::open_for_test(&api.config.database_path).unwrap();
|
||||
assert!(reopened.hosting_self_registration_enabled().unwrap());
|
||||
let (status, headers, body) = response(app, public()).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(headers.get(CACHE_CONTROL).unwrap(), "no-store");
|
||||
// Exact public shape excludes administrator identities, bearer and setup secrets.
|
||||
assert_eq!(body, json!({"self_registration_enabled":true}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosting_router_rejects_anonymous_ordinary_and_forged_roles_and_rechecks_admin_revocation()
|
||||
{
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let ordinary = seed_test_api_token(api.store.as_ref(), "hosting-ordinary");
|
||||
let admin = api
|
||||
.config_store
|
||||
.setup_hosting_admin("hosting-admin", true)
|
||||
.unwrap();
|
||||
let token = admin.initial_bearer.unwrap().access_token;
|
||||
let app = build_router(api.clone());
|
||||
for token in [None, Some(ordinary.as_str())] {
|
||||
for method in ["GET", "PUT"] {
|
||||
let mut request = settings_request(token, method);
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("x-yoi-role", "hosting-admin".parse().unwrap());
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("x-yoi-user-id", admin.user.user_id.parse().unwrap());
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("x-yoi-hosting-admin", "true".parse().unwrap());
|
||||
assert_eq!(
|
||||
response(app.clone(), request).await.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
}
|
||||
let forged = Request::builder().method("PUT").uri("/api/hosting/settings")
|
||||
.header("authorization", format!("Bearer {ordinary}"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(json!({"self_registration_enabled":true,"role":"hosting-admin","actor":admin.user,"is_admin":true}).to_string())).unwrap();
|
||||
// Public JSON cannot select an actor or add a role.
|
||||
assert_eq!(
|
||||
response(app.clone(), forged).await.0,
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
assert!(!api.store.hosting_self_registration_enabled().unwrap());
|
||||
for (token, is_admin) in [(&ordinary, false), (&token, true)] {
|
||||
let request = Request::builder()
|
||||
.uri("/api/hosting/access")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let (status, _, body) = response(app.clone(), request).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body, json!({"is_admin":is_admin}));
|
||||
}
|
||||
assert_eq!(
|
||||
response(app.clone(), settings_request(Some(&token), "PUT"))
|
||||
.await
|
||||
.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
api.config_store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM hosting_administrators WHERE user_id=?1",
|
||||
[&admin.user.user_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
for method in ["GET", "PUT"] {
|
||||
assert_eq!(
|
||||
response(app.clone(), settings_request(Some(&token), method))
|
||||
.await
|
||||
.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
assert!(api.store.hosting_self_registration_enabled().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_hosting_trait_uses_live_transport_not_forged_or_stale_actor_context() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let ordinary = seed_test_api_token(api.store.as_ref(), "direct-hosting-ordinary");
|
||||
let admin = api
|
||||
.config_store
|
||||
.setup_hosting_admin("direct-hosting-admin", true)
|
||||
.unwrap();
|
||||
let token = admin.initial_bearer.unwrap().access_token;
|
||||
let admin_actor = RequestActor {
|
||||
user_id: admin.user.user_id.clone(),
|
||||
account_id: admin.user.account_id.clone(),
|
||||
handle: admin.user.handle.clone(),
|
||||
display_name: admin.user.display_name.clone(),
|
||||
auth_method: ActorAuthMethod::ApiToken,
|
||||
};
|
||||
let service = ServerApiContractService::Workspace(api.clone());
|
||||
let update = || server_api::HostingSettings {
|
||||
self_registration_enabled: true,
|
||||
};
|
||||
for input in [
|
||||
context(None, Some(admin_actor.clone())),
|
||||
context(Some(&ordinary), Some(admin_actor.clone())),
|
||||
] {
|
||||
assert_eq!(
|
||||
service
|
||||
.hosting_settings(input.clone())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.status_code(),
|
||||
403
|
||||
);
|
||||
assert_eq!(
|
||||
service
|
||||
.hosting_settings_update(input, update())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.status_code(),
|
||||
403
|
||||
);
|
||||
}
|
||||
assert!(!api.store.hosting_self_registration_enabled().unwrap());
|
||||
// Conversely, a bogus cached actor cannot replace a valid current credential.
|
||||
let live = context(Some(&token), Some(test_owner_actor()));
|
||||
assert!(service.hosting_access(live.clone()).await.unwrap().is_admin);
|
||||
service
|
||||
.hosting_settings_update(live.clone(), update())
|
||||
.await
|
||||
.unwrap();
|
||||
api.config_store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE api_tokens SET revoked_at='2026-01-02T00:00:00Z' WHERE user_id=?1",
|
||||
[&admin.user.user_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
service
|
||||
.hosting_access(live.clone())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.status_code(),
|
||||
403
|
||||
);
|
||||
assert_eq!(
|
||||
service
|
||||
.hosting_settings_update(
|
||||
live,
|
||||
server_api::HostingSettings {
|
||||
self_registration_enabled: false
|
||||
}
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.status_code(),
|
||||
403
|
||||
);
|
||||
assert!(api.store.hosting_self_registration_enabled().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_hosting_cookie_write_requires_actual_origin_and_current_session() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let admin = api
|
||||
.config_store
|
||||
.setup_hosting_admin("cookie-hosting-admin", true)
|
||||
.unwrap();
|
||||
api.store
|
||||
.create_browser_session(&BrowserSessionRecord {
|
||||
token_hash: crate::auth::token_hash("hosting-browser-session"),
|
||||
session_id: "hosting-browser-session-id".into(),
|
||||
user_id: admin.user.user_id.clone(),
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
expires_at: "2099-01-01T00:00:00Z".into(),
|
||||
revoked_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
let AuthConfig::Passkey {
|
||||
origin,
|
||||
cookie_name,
|
||||
..
|
||||
} = &api.config.auth;
|
||||
let cookie_context = |actual_origin: Option<&str>| {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::COOKIE,
|
||||
format!("{cookie_name}=hosting-browser-session")
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
if let Some(origin) = actual_origin {
|
||||
headers.insert(ORIGIN, origin.parse().unwrap());
|
||||
}
|
||||
// The apparently trusted origin field must not bypass the retained transport header.
|
||||
server_request_context(None, None, None, Some(origin.clone()), &headers)
|
||||
};
|
||||
let service = ServerApiContractService::Workspace(api.clone());
|
||||
assert!(
|
||||
service
|
||||
.hosting_access(cookie_context(None))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_admin
|
||||
);
|
||||
for origin in [None, Some("https://attacker.invalid")] {
|
||||
assert_eq!(
|
||||
service
|
||||
.hosting_settings_update(
|
||||
cookie_context(origin),
|
||||
server_api::HostingSettings {
|
||||
self_registration_enabled: true
|
||||
}
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.status_code(),
|
||||
403
|
||||
);
|
||||
assert!(!api.store.hosting_self_registration_enabled().unwrap());
|
||||
}
|
||||
service
|
||||
.hosting_settings_update(
|
||||
cookie_context(Some(origin)),
|
||||
server_api::HostingSettings {
|
||||
self_registration_enabled: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
api.config_store.with_conn(|conn| {
|
||||
conn.execute("UPDATE browser_sessions SET revoked_at='2026-01-02T00:00:00Z' WHERE session_id='hosting-browser-session-id'", [])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert_eq!(
|
||||
service
|
||||
.hosting_settings_update(
|
||||
cookie_context(Some(origin)),
|
||||
server_api::HostingSettings {
|
||||
self_registration_enabled: false
|
||||
}
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.status_code(),
|
||||
403
|
||||
);
|
||||
assert!(api.store.hosting_self_registration_enabled().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_owner_mutations_and_upgrade_guards_do_not_expand_for_hosting_admins() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let owner =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "hosting-workspace-owner")
|
||||
.await;
|
||||
let foreign = seed_test_api_token(api.store.as_ref(), "hosting-workspace-foreign");
|
||||
let admin = api
|
||||
.config_store
|
||||
.setup_hosting_admin("workspace-foreign-admin", true)
|
||||
.unwrap();
|
||||
let admin = admin.initial_bearer.unwrap().access_token;
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let ticket = backend
|
||||
.create(ticket::NewTicket::new("Owner-only case changes"))
|
||||
.unwrap();
|
||||
let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
|
||||
catalog
|
||||
.apis
|
||||
.lock()
|
||||
.await
|
||||
.insert(TEST_WORKSPACE_ID.into(), api.clone());
|
||||
catalog
|
||||
.routers
|
||||
.lock()
|
||||
.await
|
||||
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone()));
|
||||
for (router_name, app) in [
|
||||
("local", build_router(api.clone())),
|
||||
("catalog", workspace_server_router(catalog)),
|
||||
] {
|
||||
for prefix in [
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}"),
|
||||
format!("/workspaces/{TEST_WORKSPACE_ID}"),
|
||||
] {
|
||||
let path = format!(
|
||||
"{prefix}/tickets/{}/assignment-tracking/assignments",
|
||||
ticket.id
|
||||
);
|
||||
for (token, owns_workspace) in
|
||||
[(&owner, true), (&foreign, false), (&admin, false)]
|
||||
{
|
||||
let request = |upgrade: bool| {
|
||||
let mut request = Request::builder()
|
||||
.uri(&path)
|
||||
.header("authorization", format!("Bearer {token}"));
|
||||
if upgrade {
|
||||
request = request.header(axum::http::header::UPGRADE, "websocket");
|
||||
}
|
||||
request.body(Body::empty()).unwrap()
|
||||
};
|
||||
// Existing authenticated read-only projections remain accessible.
|
||||
assert_eq!(
|
||||
response(app.clone(), request(false)).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
response(app.clone(), request(true)).await.0,
|
||||
if owns_workspace {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::FORBIDDEN
|
||||
}
|
||||
);
|
||||
let websocket = Request::builder()
|
||||
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/protocol/ws"))
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("connection", "upgrade")
|
||||
.header("upgrade", "websocket")
|
||||
.header("sec-websocket-version", "13")
|
||||
.header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let ws_status = response(app.clone(), websocket).await.0;
|
||||
if owns_workspace {
|
||||
assert_eq!(
|
||||
ws_status,
|
||||
StatusCode::UPGRADE_REQUIRED,
|
||||
"in-process router has no Hyper upgrade state, but owner passes admission"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(ws_status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
let before = backend.show(ticket.id.clone().into()).unwrap();
|
||||
let action = if before.meta.workflow_state == ticket::TicketState::Open {
|
||||
ticket::TicketCaseAction::Close {
|
||||
reason: ticket::TicketClosureReason::Completed,
|
||||
}
|
||||
} else {
|
||||
ticket::TicketCaseAction::Reopen
|
||||
};
|
||||
let input = case_test_request(
|
||||
&before,
|
||||
&format!("{router_name}-{prefix}-{owns_workspace}"),
|
||||
action,
|
||||
);
|
||||
let mut input = serde_json::to_value(input).unwrap();
|
||||
input.as_object_mut().unwrap().remove("author");
|
||||
let (mutation_path, input) = if prefix.starts_with("/api") {
|
||||
(
|
||||
format!("{prefix}/ticket-tags"),
|
||||
json!({"operation_key":router_name,"action":{"kind":"create","name":format!("owner-{router_name}")}}),
|
||||
)
|
||||
} else {
|
||||
(format!("{prefix}/tickets/{}/case", ticket.id), input)
|
||||
};
|
||||
let mutation = Request::builder()
|
||||
.method("POST")
|
||||
.uri(mutation_path)
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(input.to_string()))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response(app.clone(), mutation).await.0,
|
||||
if owns_workspace {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::FORBIDDEN
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let saved = backend.show(ticket.id.into()).unwrap();
|
||||
assert_eq!(
|
||||
saved.case_history.len(),
|
||||
2,
|
||||
"only owner canonical mutations persist"
|
||||
);
|
||||
let tags = backend
|
||||
.list_workspace_tags(ticket::TagListRequest::default())
|
||||
.unwrap();
|
||||
assert_eq!(tags.len(), 2, "only owner browser mutations persist");
|
||||
assert!(
|
||||
!api.store
|
||||
.is_hosting_admin("user-hosting-workspace-owner")
|
||||
.unwrap()
|
||||
);
|
||||
api.config_store
|
||||
.setup_hosting_admin("user-hosting-workspace-owner", false)
|
||||
.unwrap();
|
||||
request_json_authenticated(build_router(api.clone()), "POST", &format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags"), Some(json!({"operation_key":"admin-own-workspace","action":{"kind":"create","name":"admin-owned"}})), &owner, StatusCode::OK).await;
|
||||
assert_eq!(
|
||||
backend
|
||||
.list_workspace_tags(ticket::TagListRequest::default())
|
||||
.unwrap()
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_test_workspace_owner_token(
|
||||
store: &dyn ControlPlaneStore,
|
||||
suffix: &str,
|
||||
) -> String {
|
||||
let owner = store
|
||||
.get_workspace(TEST_WORKSPACE_ID)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
seed_test_api_token_for_account(store, suffix, owner.owner_account_id)
|
||||
}
|
||||
|
||||
fn seed_test_api_token(store: &dyn ControlPlaneStore, suffix: &str) -> String {
|
||||
let account_id = format!("account-{suffix}");
|
||||
let user_id = format!("user-{suffix}");
|
||||
let token = format!("api-token-{suffix}");
|
||||
store
|
||||
.upsert_account(&AccountRecord {
|
||||
account_id: account_id.clone(),
|
||||
@@ -40046,6 +40742,16 @@ mod tests {
|
||||
updated_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||
})
|
||||
.unwrap();
|
||||
seed_test_api_token_for_account(store, suffix, account_id)
|
||||
}
|
||||
|
||||
fn seed_test_api_token_for_account(
|
||||
store: &dyn ControlPlaneStore,
|
||||
suffix: &str,
|
||||
account_id: String,
|
||||
) -> String {
|
||||
let user_id = format!("user-{suffix}");
|
||||
let token = format!("api-token-{suffix}");
|
||||
store
|
||||
.upsert_user(&UserRecord {
|
||||
user_id: user_id.clone(),
|
||||
@@ -50824,6 +51530,7 @@ mod tests {
|
||||
async fn new() -> Self {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut api = test_api(root.path()).await;
|
||||
enable_test_self_registration(&api);
|
||||
let AuthConfig::Passkey {
|
||||
rp_id,
|
||||
origin,
|
||||
@@ -52295,6 +53002,7 @@ mod tests {
|
||||
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let mut api = test_api(workspace.path()).await;
|
||||
enable_test_self_registration(&api);
|
||||
let AuthConfig::Passkey {
|
||||
rp_id,
|
||||
origin,
|
||||
@@ -56658,6 +57366,27 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// These registration verifier suites explicitly opt in. Their fixture does
|
||||
// not redefine fresh-install defaults or make the public registrant an admin.
|
||||
fn enable_test_self_registration(api: &WorkspaceApi) {
|
||||
let setup = api
|
||||
.config_store
|
||||
.setup_hosting_admin("test-policy-admin", true)
|
||||
.unwrap();
|
||||
api.store
|
||||
.set_hosting_self_registration_enabled(
|
||||
&RequestActor {
|
||||
user_id: setup.user.user_id,
|
||||
account_id: setup.user.account_id,
|
||||
handle: setup.user.handle,
|
||||
display_name: setup.user.display_name,
|
||||
auth_method: ActorAuthMethod::ApiToken,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn test_owner_actor() -> RequestActor {
|
||||
RequestActor {
|
||||
user_id: "owner-user".to_string(),
|
||||
@@ -58285,7 +59014,7 @@ mod tests {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(workspace.path());
|
||||
let api = test_api(workspace.path()).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), "workdir-removal");
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "workdir-removal").await;
|
||||
let workdir_id = "account-occupied-workdir";
|
||||
seed_cleanup_workdir_for_runtime(
|
||||
&api,
|
||||
@@ -58320,12 +59049,15 @@ mod tests {
|
||||
.find_workdir_removal_operation_by_intent(
|
||||
TEST_WORKSPACE_ID,
|
||||
workdir_id,
|
||||
"account:account-workdir-removal",
|
||||
&format!("account:account-{TEST_WORKSPACE_ID}"),
|
||||
reason,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(operation.source_actor, "account:account-workdir-removal");
|
||||
assert_eq!(
|
||||
operation.source_actor,
|
||||
format!("account:account-{TEST_WORKSPACE_ID}")
|
||||
);
|
||||
assert_eq!(
|
||||
operation.disposition,
|
||||
Some(WorkdirRemovalDisposition::Retained)
|
||||
@@ -61836,7 +62568,9 @@ mod tests {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
let api = test_api(dir.path()).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), "explicit-runtime-no-fallback");
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "explicit-runtime-no-fallback")
|
||||
.await;
|
||||
set_test_default_runtime(&api, EMBEDDED_WORKER_RUNTIME_ID);
|
||||
let operation_id = "workdir-create-explicit-runtime";
|
||||
|
||||
@@ -61881,7 +62615,9 @@ mod tests {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
let api = test_api(dir.path()).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), "migrated-workdir-create-replay");
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "migrated-workdir-create-replay")
|
||||
.await;
|
||||
let operation_id = "migrated-workdir-create";
|
||||
// Schema-86 hash of (test-repository, frozen-selector, None,
|
||||
// Original Name, sha256:frozen-source), followed by source ordinal 9.
|
||||
@@ -62002,7 +62738,8 @@ mod tests {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
let api = test_api(dir.path()).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), "stale-workdir-create-json");
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "stale-workdir-create-json").await;
|
||||
|
||||
let response = request_json_authenticated(
|
||||
build_router(api.clone()),
|
||||
@@ -62038,7 +62775,8 @@ mod tests {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
let api = test_api(dir.path()).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), "missing-workdir-repository");
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "missing-workdir-repository").await;
|
||||
|
||||
let response = request_json_authenticated(
|
||||
build_router(api.clone()),
|
||||
@@ -62073,7 +62811,8 @@ mod tests {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
let api = test_api(dir.path()).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), "failed-default-resolution");
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "failed-default-resolution").await;
|
||||
set_test_default_runtime(&api, EMBEDDED_WORKER_RUNTIME_ID);
|
||||
let original_projection_digest = api
|
||||
.config_store
|
||||
@@ -65769,7 +66508,9 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn passkey_registration_rejects_unverified_credential_response() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let app = test_app(dir.path()).await;
|
||||
let api = test_api(dir.path()).await;
|
||||
enable_test_self_registration(&api);
|
||||
let app = build_inner_router(api);
|
||||
|
||||
let registration_options = post_json(
|
||||
app.clone(),
|
||||
|
||||
@@ -506,7 +506,7 @@ impl Drop for AbortServer {
|
||||
async fn drive_generated_client_roundtrips_binary_and_declared_headers_over_real_http() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let api = test_api(temp.path()).await;
|
||||
seed_test_api_token(api.store.as_ref(), "drive-client");
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "drive-client").await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let app = build_router(api);
|
||||
|
||||
@@ -32,6 +32,7 @@ struct RealDriveFixture {
|
||||
api: WorkspaceApi,
|
||||
owner: String,
|
||||
second: String,
|
||||
foreign: String,
|
||||
_temp: tempfile::TempDir,
|
||||
}
|
||||
impl RealDriveFixture {
|
||||
@@ -41,7 +42,7 @@ impl RealDriveFixture {
|
||||
config.repositories.clear();
|
||||
let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap());
|
||||
let owner = seed_test_api_token(store.as_ref(), "drive-web-owner");
|
||||
let second = seed_test_api_token(store.as_ref(), "drive-web-second");
|
||||
let foreign = seed_test_api_token(store.as_ref(), "drive-web-foreign");
|
||||
let server_api = WorkspaceServerApi::new_for_test(config, store.clone());
|
||||
let server = LoopbackServer::start(workspace_server_router(server_api.clone())).await;
|
||||
// Exercise T-717's real repositoryless Workspace bootstrap, not a
|
||||
@@ -56,6 +57,25 @@ impl RealDriveFixture {
|
||||
let created = http_json(response, StatusCode::CREATED).await;
|
||||
assert!(created["repository"].is_null(), "{created}");
|
||||
let workspace_id = created["workspace"]["workspace_id"].as_str().unwrap();
|
||||
// Both concurrent clients need current owner authority. A foreign
|
||||
// Account must be rejected by admission before it can reach Drive CAS.
|
||||
let second = "api-token-drive-web-second".to_string();
|
||||
store
|
||||
.create_api_token(&ApiTokenRecord {
|
||||
token_hash: crate::auth::token_hash(&second),
|
||||
token_id: "drive-web-second-token".into(),
|
||||
user_id: store
|
||||
.get_user_by_handle("user-drive-web-owner")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.user_id,
|
||||
label: "second owner client".into(),
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
expires_at: None,
|
||||
last_used_at: None,
|
||||
revoked_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(store.list_repositories(workspace_id).unwrap().is_empty());
|
||||
let api = server_api
|
||||
.api_for_workspace(workspace_id)
|
||||
@@ -76,6 +96,7 @@ impl RealDriveFixture {
|
||||
api,
|
||||
owner,
|
||||
second,
|
||||
foreign,
|
||||
_temp: temp,
|
||||
}
|
||||
}
|
||||
@@ -405,7 +426,7 @@ async fn repositoryless_drive_real_web_adapter_preserves_bytes_cas_and_latest_re
|
||||
.expect("Deno must be installed for this explicit process test");
|
||||
let wire = serde_json::to_vec(&json!({
|
||||
"origin":fixture.server.origin,"workspace_id":fixture.api.workspace_id(),
|
||||
"owner_token":fixture.owner,"second_token":fixture.second,"worker_entry":worker_entry,
|
||||
"owner_token":fixture.owner,"second_token":fixture.second,"foreign_token":fixture.foreign,"worker_entry":worker_entry,
|
||||
}))
|
||||
.unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
@@ -422,6 +443,7 @@ async fn repositoryless_drive_real_web_adapter_preserves_bytes_cas_and_latest_re
|
||||
String::from_utf8_lossy(bytes)
|
||||
.replace(&fixture.owner, "[redacted]")
|
||||
.replace(&fixture.second, "[redacted]")
|
||||
.replace(&fixture.foreign, "[redacted]")
|
||||
};
|
||||
assert!(
|
||||
output.status.success(),
|
||||
|
||||
@@ -195,7 +195,8 @@ async fn unfinished_dependencies_allow_manual_and_orchestrator_resource_free_sta
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let token = seed_test_api_token(api.store.as_ref(), "partial-start-owner");
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "partial-start-owner").await;
|
||||
let ticket = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.create(ticket::NewTicket::new(
|
||||
@@ -349,7 +350,8 @@ async fn unfinished_dependencies_allow_read_only_start_and_assign_without_write_
|
||||
assign_test_orchestrator(api, &fixture.ticket_id);
|
||||
}
|
||||
let delegation = observed_start_delegation(api, &fixture.ticket_id, source.as_ref());
|
||||
let token = seed_test_api_token(api.store.as_ref(), "read-only-start-owner");
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "read-only-start-owner").await;
|
||||
let worker = if existing {
|
||||
// Drop the unrelated checkout so the entire attachment set is reference-only.
|
||||
let main = api
|
||||
@@ -482,7 +484,7 @@ async fn unfinished_dependencies_do_not_bypass_http_auth_closed_or_resource_bind
|
||||
let fixture = manual_worker_assignment_fixture().await;
|
||||
let api = &fixture.api;
|
||||
add_unfinished_start_dependency(api, &fixture.ticket_id);
|
||||
let token = seed_test_api_token(api.store.as_ref(), "guard-start-owner");
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "guard-start-owner").await;
|
||||
let path = format!(
|
||||
"/api/w/{TEST_WORKSPACE_ID}/tickets/{}/assignments/worker",
|
||||
fixture.ticket_id
|
||||
@@ -544,7 +546,8 @@ async fn orchestrator_start_and_assign_reject_missing_stale_cleared_or_reissued_
|
||||
let runtime = WorkdirlessFixtureRuntime::default();
|
||||
api.runtime.register_or_replace(runtime.clone());
|
||||
let source = start_source(&mut api, &runtime).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), "delegation-owner");
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "delegation-owner").await;
|
||||
let ticket = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.create(ticket::NewTicket::new("Observed delegation start"))
|
||||
@@ -665,7 +668,8 @@ async fn replaced_orchestrator_cannot_start_or_assign_under_retained_current_del
|
||||
let runtime = WorkdirlessFixtureRuntime::default();
|
||||
api.runtime.register_or_replace(runtime.clone());
|
||||
let source = start_source(&mut api, &runtime).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), "obsolete-worker-owner");
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "obsolete-worker-owner").await;
|
||||
let ticket = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.create(ticket::NewTicket::new(
|
||||
|
||||
@@ -5,7 +5,8 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_
|
||||
for runtime_source in [true, false] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), "assign-replay-owner");
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "assign-replay-owner").await;
|
||||
let request = |method: &str, path: &str, body: Vec<u8>| {
|
||||
if runtime_source {
|
||||
runtime_source_request(&identity, Some(&source.worker_id), method, path, body)
|
||||
@@ -370,7 +371,8 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
|
||||
{
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, _, _, _, ticket) = tracking_fixture(dir.path()).await;
|
||||
let owner = seed_test_api_token(api.store.as_ref(), "assignment-account-owner");
|
||||
let owner =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "assignment-account-owner").await;
|
||||
let other = seed_test_api_token(api.store.as_ref(), "assignment-account-other");
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
|
||||
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"account-assign", "principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap();
|
||||
@@ -393,7 +395,7 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
|
||||
let actor = format!(
|
||||
"workspace-user:{}",
|
||||
serde_json::to_string(&(
|
||||
"account-assignment-account-owner",
|
||||
&format!("account-{TEST_WORKSPACE_ID}"),
|
||||
"user-assignment-account-owner"
|
||||
))
|
||||
.unwrap()
|
||||
@@ -411,7 +413,7 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
request(&other, "PUT", &path, body),
|
||||
StatusCode::CONFLICT,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
let assignment_id = assigned["assignment"]["assignment_id"].as_str().unwrap();
|
||||
@@ -434,7 +436,7 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
|
||||
tracking_response(
|
||||
app,
|
||||
request(&other, "DELETE", &clear_path, Vec::new()),
|
||||
StatusCode::CONFLICT,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
let released = api
|
||||
@@ -1105,7 +1107,7 @@ async fn assignment_retry_is_idempotent_but_another_authenticated_actor_cannot_r
|
||||
let current = assign_tracking(&api, &identity, &source, &ticket).await;
|
||||
let replay = assign_tracking(&api, &identity, &source, &ticket).await;
|
||||
assert_eq!(replay, current);
|
||||
let token = seed_test_api_token(api.store.as_ref(), "assignment-replay-user");
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "assignment-replay-user").await;
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
|
||||
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap();
|
||||
tracking_response(
|
||||
@@ -1226,7 +1228,8 @@ async fn tracking_model_authorship_and_role_fields_are_rejected_and_human_is_not
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let token = seed_test_api_token(api.store.as_ref(), "human-not-tracking-agent");
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "human-not-tracking-agent").await;
|
||||
tracking_response(
|
||||
app,
|
||||
Request::builder()
|
||||
@@ -1308,7 +1311,7 @@ async fn assignment_clear_receipt_binds_verified_actor_and_runtime_binding() {
|
||||
.await,
|
||||
result
|
||||
);
|
||||
let token = seed_test_api_token(api.store.as_ref(), "another-clear-actor");
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "another-clear-actor").await;
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
Request::builder()
|
||||
@@ -1517,7 +1520,7 @@ async fn public_assignment_answer_authenticates_source_replays_saved_result_and_
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await;
|
||||
let current = assign_tracking(&api, &identity, &source, &ticket).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), "answer-owner");
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "answer-owner").await;
|
||||
let app = build_router(api.clone());
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer");
|
||||
let body = serde_json::json!({"assignment_id":current.assignment_id,"expected_last_event_id":current.last_event_id,"operation_key":"answer-op","body":"Choose the bounded option"});
|
||||
@@ -1755,7 +1758,7 @@ async fn catalog_assignment_routes_apply_cookie_workspace_auth_and_origin_to_bro
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await;
|
||||
let current = assign_tracking(&api, &identity, &source, &ticket).await;
|
||||
seed_test_api_token(api.store.as_ref(), "assignment-cookie");
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "assignment-cookie").await;
|
||||
api.store
|
||||
.create_browser_session(&BrowserSessionRecord {
|
||||
token_hash: crate::auth::token_hash("assignment-browser-session"),
|
||||
@@ -1846,7 +1849,7 @@ async fn catalog_assignment_routes_apply_cookie_workspace_auth_and_origin_to_bro
|
||||
.header(ORIGIN, origin)
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap(),
|
||||
StatusCode::NOT_FOUND,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -1865,7 +1868,7 @@ async fn catalog_assignment_routes_apply_cookie_workspace_auth_and_origin_to_bro
|
||||
.author
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("account-assignment-cookie")
|
||||
.contains(&format!("account-{TEST_WORKSPACE_ID}"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -577,7 +577,7 @@ async fn typed_and_generic_case_dispatch_keep_the_same_verified_worker_and_recei
|
||||
async fn authenticated_human_case_actor_and_replay_are_bound_to_verified_account_and_user() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), "case-human");
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "case-human").await;
|
||||
let other_token = seed_test_api_token(api.store.as_ref(), "other-human");
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let reference = backend
|
||||
@@ -613,11 +613,17 @@ async fn authenticated_human_case_actor_and_replay_are_bound_to_verified_account
|
||||
let response = case_response(typed.clone(), human_request(&token, true), StatusCode::OK).await;
|
||||
let closed = backend.show(reference.id.clone().into()).unwrap();
|
||||
let event = closed.events.last().unwrap();
|
||||
assert_eq!(event.attributes["source_account_id"], "account-case-human");
|
||||
assert_eq!(
|
||||
event.attributes["source_account_id"],
|
||||
format!("account-{TEST_WORKSPACE_ID}")
|
||||
);
|
||||
assert_eq!(event.attributes["source_user_id"], "user-case-human");
|
||||
assert_eq!(
|
||||
event.author.as_deref(),
|
||||
Some("workspace-user:[\"account-case-human\",\"user-case-human\"]")
|
||||
Some(
|
||||
format!("workspace-user:[\"account-{TEST_WORKSPACE_ID}\",\"user-case-human\"]")
|
||||
.as_str()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
closed.case_history.last().unwrap().author,
|
||||
@@ -630,7 +636,7 @@ async fn authenticated_human_case_actor_and_replay_are_bound_to_verified_account
|
||||
case_response(
|
||||
typed,
|
||||
human_request(&other_token, true),
|
||||
StatusCode::CONFLICT,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
// Generic dispatch receives an explicit untrusted author but must override it.
|
||||
|
||||
@@ -858,16 +858,15 @@ async fn ticket_tag_routes_require_workspace_authority_and_reject_forged_worker_
|
||||
)
|
||||
.await;
|
||||
assert_eq!(tag["workspace_id"], TEST_WORKSPACE_ID);
|
||||
// Classification follows shared Ticket Workspace write authority, not the
|
||||
// owner-only repository/secrets-management policy. A distinct authenticated
|
||||
// actor may manage tags but cannot replay another actor's receipt.
|
||||
// An authenticated foreign account cannot mutate classification or replay
|
||||
// the owner's receipt; classification does not grant Workspace authority.
|
||||
request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&path,
|
||||
Some(json!({"operation_key":"authorized", "action":{"kind":"create", "name":"ready"}})),
|
||||
&collaborator_token,
|
||||
StatusCode::CONFLICT,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
request_json_authenticated(
|
||||
@@ -876,7 +875,7 @@ async fn ticket_tag_routes_require_workspace_authority_and_reject_forged_worker_
|
||||
"/api/w/other-workspace/ticket-tags",
|
||||
Some(json!({"operation_key":"foreign", "action":{"kind":"create", "name":"foreign"}})),
|
||||
&collaborator_token,
|
||||
StatusCode::NOT_FOUND,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
let response = app
|
||||
|
||||
@@ -15,6 +15,38 @@
|
||||
//! migration before it starts. Updating only the new schema would diverge from existing DBs;
|
||||
//! both creation paths must maintain the same contract.
|
||||
//!
|
||||
//! # Hosting administrator setup
|
||||
//!
|
||||
//! Fresh and migrated databases have no hosting administrators and disable public
|
||||
//! self-registration. Users, Workspace owners and holders of the legacy bootstrap token
|
||||
//! are not promoted automatically. Hosting grants are separate from Workspace permissions.
|
||||
//!
|
||||
//! On the trusted Server host, run `yoi-server hosting-admin --database <PATH> --handle
|
||||
//! <NEW_HANDLE> --create-user` for a fresh installation. This atomically creates a user
|
||||
//! and explicit hosting grant, and intentionally prints one bearer token, valid for
|
||||
//! fifteen minutes. Keep stdout private: the token is a full user bearer, not a scoped
|
||||
//! activation code. Start the Server with its normal Web static assets and configured
|
||||
//! WebAuthn origin (HTTPS, or localhost). Open `/hosting/setup` on that trusted origin,
|
||||
//! enter the host-issued token in its password field, and choose Add administrator
|
||||
//! passkey. The page uses actor-derived addition, not public registration; it never
|
||||
//! stores the token or puts it in URLs. After enrollment choose Sign in with new
|
||||
//! passkey, then Open hosting settings. No Workspace or public registration is needed.
|
||||
//! The initial bearer expires automatically; never put it in URLs, docs or shared logs.
|
||||
//!
|
||||
//! For an existing installation, back up the DB and run migrations, then explicitly
|
||||
//! select a known user with `yoi-server hosting-admin --database <PATH> --handle
|
||||
//! <EXISTING_HANDLE>` without `--create-user`. Verify the handle belongs to the intended
|
||||
//! person first. This grants authority without minting or replacing login credentials;
|
||||
//! that person uses their existing passkey. `bootstrap-auth` retains its old host-user
|
||||
//! setup behavior but grants no hosting authority.
|
||||
//!
|
||||
//! Repeated creation refuses an existing handle and never refreshes a token. If the
|
||||
//! initial token is lost or expires before enrollment, a trusted host operator can
|
||||
//! explicitly create a different administrator handle and enroll it, then remove the
|
||||
//! unused user's grant and tokens through trusted DB maintenance after a backup.
|
||||
//! Selecting an existing user is not a credential recovery mechanism; no public
|
||||
//! bootstrap or unauthenticated administrator recovery endpoint exists.
|
||||
//!
|
||||
//! # Resource state and operation records
|
||||
//!
|
||||
//! Workspace bootstrap commits resources and creation receipts in one transaction. Jobs and
|
||||
@@ -84,7 +116,7 @@ pub use ticket::{
|
||||
};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 98;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 99;
|
||||
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
|
||||
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
@@ -382,6 +414,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "Assignment-independent implementation reports",
|
||||
apply: migrate_result_reports_v97_to_v98,
|
||||
},
|
||||
Migration {
|
||||
version: 99,
|
||||
name: "Explicit hosting administrators and self-registration policy",
|
||||
apply: migrate_hosting_policy_v98_to_v99,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -1222,6 +1259,17 @@ impl WorkspaceResourceKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of an explicitly trusted host setup. Secrets deliberately have no Debug output.
|
||||
pub struct HostingAdminSetup {
|
||||
pub user: UserRecord,
|
||||
pub initial_bearer: Option<HostingAdminInitialBearer>,
|
||||
}
|
||||
|
||||
pub struct HostingAdminInitialBearer {
|
||||
pub access_token: String,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
/// Bounded owner for whole synchronous operations from async callers. Move owned inputs
|
||||
@@ -1615,6 +1663,15 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
fn get_user(&self, user_id: &str) -> Result<Option<UserRecord>>;
|
||||
fn get_user_by_handle(&self, handle: &str) -> Result<Option<UserRecord>>;
|
||||
fn any_user(&self) -> Result<Option<UserRecord>>;
|
||||
/// Checks an explicit grant for a currently existing user, not Workspace ownership.
|
||||
fn is_hosting_admin(&self, user_id: &str) -> Result<bool>;
|
||||
fn hosting_self_registration_enabled(&self) -> Result<bool>;
|
||||
/// Rechecks the actor's live user/account and hosting grant in the write transaction.
|
||||
fn set_hosting_self_registration_enabled(
|
||||
&self,
|
||||
actor: &server_api::RequestActor,
|
||||
enabled: bool,
|
||||
) -> Result<()>;
|
||||
/// Atomically creates a new account/user/credential, or inserts a credential
|
||||
/// for a still-authenticated existing target. Never overwrites a credential.
|
||||
fn register_verified_passkey(
|
||||
@@ -2608,6 +2665,61 @@ impl SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Host-only setup; never expose this operation through a public bootstrap route.
|
||||
/// Creating a user, granting hosting authority and issuing its short-lived initial
|
||||
/// bearer are atomic. Selecting an existing user never issues credentials. A repeat
|
||||
/// create is refused, including after output loss; it cannot mint another bearer.
|
||||
pub fn setup_hosting_admin(
|
||||
&self,
|
||||
handle: &str,
|
||||
create_user: bool,
|
||||
) -> Result<HostingAdminSetup> {
|
||||
let handle = crate::auth::normalize_handle(handle)?;
|
||||
self.with_immediate_transaction(|tx| {
|
||||
let existing = tx.query_row(
|
||||
user_select_sql("WHERE handle = ?1").as_str(),
|
||||
[&handle], read_user_record,
|
||||
).optional()?;
|
||||
let now = chrono::Utc::now();
|
||||
let created_at = now.to_rfc3339();
|
||||
let (user, initial_bearer) = match (existing, create_user) {
|
||||
(Some(_), true) => return Err(Error::Store("hosting-admin --create-user requires a new handle; no credentials issued".into())),
|
||||
(None, false) => return Err(Error::Store("hosting-admin requires an existing user; use --create-user explicitly for a new user".into())),
|
||||
(Some(user), false) => (user, None),
|
||||
(None, true) => {
|
||||
let user = UserRecord {
|
||||
user_id: crate::auth::new_id("user"),
|
||||
account_id: crate::auth::new_id("acct-user"),
|
||||
handle: handle.clone(),
|
||||
display_name: handle.clone(),
|
||||
created_at: created_at.clone(),
|
||||
updated_at: created_at.clone(),
|
||||
};
|
||||
tx.execute(
|
||||
"INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES(?1,'user',?2,?3,?4,?4)",
|
||||
params![user.account_id, user.handle, user.display_name, created_at],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO users(user_id,account_id,handle,display_name,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,?5)",
|
||||
params![user.user_id, user.account_id, user.handle, user.display_name, created_at],
|
||||
)?;
|
||||
let access_token = crate::auth::mint_secret("yoi_api");
|
||||
let expires_at = (now + chrono::Duration::minutes(15)).to_rfc3339();
|
||||
tx.execute(
|
||||
"INSERT INTO api_tokens(token_id,token_hash,user_id,label,created_at,expires_at) VALUES(?1,?2,?3,'initial hosting administrator passkey setup',?4,?5)",
|
||||
params![crate::auth::new_id("api-token"), crate::auth::token_hash(&access_token), user.user_id, created_at, expires_at],
|
||||
)?;
|
||||
(user, Some(HostingAdminInitialBearer { access_token, expires_at }))
|
||||
}
|
||||
};
|
||||
tx.execute(
|
||||
"INSERT INTO hosting_administrators(user_id,created_at) VALUES(?1,?2) ON CONFLICT(user_id) DO NOTHING",
|
||||
params![user.user_id, created_at],
|
||||
)?;
|
||||
Ok(HostingAdminSetup { user, initial_bearer })
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn get_internal_worker_restore_intent(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -6447,6 +6559,51 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_hosting_admin(&self, user_id: &str) -> Result<bool> {
|
||||
self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM hosting_administrators h JOIN users u ON u.user_id = h.user_id WHERE u.user_id = ?1)",
|
||||
[user_id], |row| row.get(0),
|
||||
).map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn hosting_self_registration_enabled(&self) -> Result<bool> {
|
||||
self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT self_registration_enabled FROM hosting_settings WHERE singleton = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_hosting_self_registration_enabled(
|
||||
&self,
|
||||
actor: &server_api::RequestActor,
|
||||
enabled: bool,
|
||||
) -> Result<()> {
|
||||
self.with_immediate_transaction(|tx| {
|
||||
let allowed: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM hosting_administrators h JOIN users u ON u.user_id = h.user_id WHERE u.user_id = ?1 AND u.account_id = ?2)",
|
||||
params![actor.user_id, actor.account_id], |row| row.get(0),
|
||||
)?;
|
||||
if !allowed {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"hosting_admin_required: a live hosting administrator is required".into(),
|
||||
));
|
||||
}
|
||||
if tx.execute(
|
||||
"UPDATE hosting_settings SET self_registration_enabled = ?1 WHERE singleton = 1",
|
||||
[enabled],
|
||||
)? != 1 {
|
||||
return Err(Error::Store("hosting settings singleton is missing".into()));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn register_verified_passkey(
|
||||
&self,
|
||||
target: &PasskeyRegistrationTarget,
|
||||
@@ -6455,6 +6612,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
self.with_immediate_transaction(|tx| {
|
||||
let user = match target {
|
||||
PasskeyRegistrationTarget::NewAccount { user } => {
|
||||
let enabled: bool = tx.query_row(
|
||||
"SELECT self_registration_enabled FROM hosting_settings WHERE singleton = 1",
|
||||
[], |row| row.get(0),
|
||||
)?;
|
||||
if !enabled {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"self_registration_disabled: hosting self-registration is disabled".into(),
|
||||
));
|
||||
}
|
||||
let occupied: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM accounts WHERE kind = 'user' AND handle = ?1) OR EXISTS(SELECT 1 FROM users WHERE handle = ?1)",
|
||||
params![user.handle], |row| row.get(0),
|
||||
@@ -14597,6 +14763,33 @@ fn migrate_result_reports_v97_to_v98(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_hosting_policy_v98_to_v99(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 98 {
|
||||
return Err(Error::Store(
|
||||
"Hosting policy migration requires schema 98".into(),
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(
|
||||
"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);",
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(99,?1)",
|
||||
["Explicit hosting administrators and self-registration policy"],
|
||||
)?;
|
||||
verify_workspace_resource_constraints(&tx)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
create_workspace_schema_structure(conn)?;
|
||||
assignment_delivery::install(conn)?;
|
||||
@@ -17500,6 +17693,368 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hosting_user(handle: &str) -> UserRecord {
|
||||
UserRecord {
|
||||
user_id: format!("user-{handle}"),
|
||||
account_id: format!("account-{handle}"),
|
||||
handle: handle.into(),
|
||||
display_name: handle.into(),
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn hosting_seed_user(store: &SqliteWorkspaceStore, handle: &str) -> UserRecord {
|
||||
let user = hosting_user(handle);
|
||||
store
|
||||
.upsert_account(&AccountRecord {
|
||||
account_id: user.account_id.clone(),
|
||||
kind: "user".into(),
|
||||
handle: user.handle.clone(),
|
||||
display_name: user.display_name.clone(),
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
})
|
||||
.unwrap();
|
||||
store.upsert_user(&user).unwrap();
|
||||
user
|
||||
}
|
||||
|
||||
fn hosting_actor(user: &UserRecord) -> server_api::RequestActor {
|
||||
server_api::RequestActor {
|
||||
user_id: user.user_id.clone(),
|
||||
account_id: user.account_id.clone(),
|
||||
handle: user.handle.clone(),
|
||||
display_name: user.display_name.clone(),
|
||||
auth_method: server_api::ActorAuthMethod::ApiToken,
|
||||
}
|
||||
}
|
||||
|
||||
fn hosting_credential(user: &UserRecord, id: &str) -> PasskeyCredentialRecord {
|
||||
PasskeyCredentialRecord {
|
||||
credential_id: id.into(),
|
||||
user_id: user.user_id.clone(),
|
||||
public_key_cose: "verified-key".into(),
|
||||
transports_json: None,
|
||||
sign_count: 0,
|
||||
created_at: "1".into(),
|
||||
last_used_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosting_policy_migrates_oldest_and_previous_databases_without_promoting_users() {
|
||||
for baseline in [50, 98] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.db");
|
||||
if baseline == 50 {
|
||||
prepare_schema_v50(&path, None);
|
||||
} else {
|
||||
prepare_retained_schema(&path, baseline);
|
||||
}
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch("INSERT OR IGNORE INTO accounts VALUES('old-account','user','old-user','Old','1','1');
|
||||
INSERT INTO users VALUES('old-user','old-account','old-user','Old','1','1');").unwrap();
|
||||
drop(conn);
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
assert!(!store.hosting_self_registration_enabled().unwrap());
|
||||
assert!(!store.is_hosting_admin("old-user").unwrap());
|
||||
assert_eq!(
|
||||
store.get_user("old-user").unwrap().unwrap().display_name,
|
||||
"Old"
|
||||
);
|
||||
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
for table in ["hosting_settings", "hosting_administrators"] {
|
||||
let schema = |store: &SqliteWorkspaceStore| {
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
let ddl: String = conn.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name=?1",
|
||||
[table],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(ddl
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.collect::<String>())
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(schema(&store), schema(&fresh));
|
||||
}
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
assert!(
|
||||
conn.execute(
|
||||
"INSERT INTO hosting_administrators VALUES('missing','1')",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE hosting_settings SET self_registration_enabled=2",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
conn.execute("INSERT INTO hosting_settings VALUES(2,0)", [])
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| r
|
||||
.get::<_, i64>(0))?,
|
||||
0
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosting_policy_migration_failure_rolls_back_new_tables_and_history() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.db");
|
||||
prepare_retained_schema(&path, 98);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
// A conflicting second DDL statement fails after the first table was created.
|
||||
conn.execute_batch("CREATE TABLE hosting_settings(original TEXT); INSERT INTO hosting_settings VALUES('retained');").unwrap();
|
||||
assert!(migrate_hosting_policy_v98_to_v99(&conn).is_err());
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 98);
|
||||
assert!(!table_exists(&conn, "hosting_administrators").unwrap());
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT original FROM hosting_settings", [], |r| r
|
||||
.get::<_, String>(0))
|
||||
.unwrap(),
|
||||
"retained"
|
||||
);
|
||||
conn.execute_batch("DROP TABLE hosting_settings").unwrap();
|
||||
drop(conn);
|
||||
let store = SqliteWorkspaceStore::open_for_test(path).unwrap();
|
||||
assert!(!store.hosting_self_registration_enabled().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosting_policy_requires_explicit_live_admin_and_persists_across_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.db");
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
let owner = hosting_seed_user(&store, "owner");
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: "workspace".into(),
|
||||
owner_account_id: owner.account_id.clone(),
|
||||
display_name: "Workspace".into(),
|
||||
state: "active".into(),
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!store.is_hosting_admin(&owner.user_id).unwrap());
|
||||
assert!(matches!(
|
||||
store.set_hosting_self_registration_enabled(&hosting_actor(&owner), true),
|
||||
Err(Error::WorkspacePermissionDenied(_))
|
||||
));
|
||||
assert!(!store.hosting_self_registration_enabled().unwrap());
|
||||
let setup = store.setup_hosting_admin("owner", false).unwrap();
|
||||
assert!(setup.initial_bearer.is_none());
|
||||
let mut forged = hosting_actor(&owner);
|
||||
forged.account_id = "another-account".into();
|
||||
assert!(matches!(
|
||||
store.set_hosting_self_registration_enabled(&forged, true),
|
||||
Err(Error::WorkspacePermissionDenied(_))
|
||||
));
|
||||
store
|
||||
.set_hosting_self_registration_enabled(&hosting_actor(&owner), true)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
assert!(store.hosting_self_registration_enabled().unwrap());
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute("DELETE FROM hosting_administrators", [])?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
store.set_hosting_self_registration_enabled(&hosting_actor(&owner), false),
|
||||
Err(Error::WorkspacePermissionDenied(_))
|
||||
));
|
||||
assert!(store.hosting_self_registration_enabled().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosting_setup_creates_finite_bearer_once_and_existing_selection_mints_nothing() {
|
||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
assert!(!store.hosting_self_registration_enabled().unwrap());
|
||||
assert!(store.setup_hosting_admin("unknown", false).is_err());
|
||||
let setup = store.setup_hosting_admin("new-admin", true).unwrap();
|
||||
assert!(store.is_hosting_admin(&setup.user.user_id).unwrap());
|
||||
let bearer = setup.initial_bearer.unwrap();
|
||||
let token = store
|
||||
.resolve_api_token(&crate::auth::token_hash(&bearer.access_token))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(token.user_id, setup.user.user_id);
|
||||
assert_eq!(
|
||||
token.expires_at.as_deref(),
|
||||
Some(bearer.expires_at.as_str())
|
||||
);
|
||||
let lifetime = chrono::DateTime::parse_from_rfc3339(&bearer.expires_at).unwrap()
|
||||
- chrono::DateTime::parse_from_rfc3339(&token.created_at).unwrap();
|
||||
assert_eq!(lifetime.num_seconds(), 15 * 60);
|
||||
assert!(store.setup_hosting_admin("new-admin", true).is_err());
|
||||
assert!(
|
||||
store
|
||||
.setup_hosting_admin("new-admin", false)
|
||||
.unwrap()
|
||||
.initial_bearer
|
||||
.is_none()
|
||||
);
|
||||
let existing = hosting_seed_user(&store, "existing");
|
||||
assert!(!store.is_hosting_admin(&existing.user_id).unwrap());
|
||||
assert!(
|
||||
store
|
||||
.setup_hosting_admin("existing", false)
|
||||
.unwrap()
|
||||
.initial_bearer
|
||||
.is_none()
|
||||
);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT count(*) FROM api_tokens", [], |r| r
|
||||
.get::<_, i64>(0))?,
|
||||
1
|
||||
);
|
||||
conn.execute("DELETE FROM users WHERE user_id=?1", [&setup.user.user_id])?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!store.is_hosting_admin(&setup.user.user_id).unwrap());
|
||||
assert!(matches!(
|
||||
store.set_hosting_self_registration_enabled(&hosting_actor(&setup.user), true),
|
||||
Err(Error::WorkspacePermissionDenied(_))
|
||||
));
|
||||
assert!(!store.hosting_self_registration_enabled().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosting_setup_failure_rolls_back_account_user_token_and_grant() {
|
||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
store.with_conn(|conn| { conn.execute_batch("CREATE TRIGGER reject_admin BEFORE INSERT ON hosting_administrators BEGIN SELECT RAISE(ABORT,'injected grant failure'); END;")?; Ok(()) }).unwrap();
|
||||
assert!(store.setup_hosting_admin("admin", true).is_err());
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
for table in ["accounts", "users", "api_tokens", "hosting_administrators"] {
|
||||
assert_eq!(
|
||||
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r
|
||||
.get::<_, i64>(0))?,
|
||||
0
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!store.hosting_self_registration_enabled().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosting_registration_rechecks_policy_but_allows_authenticated_addition() {
|
||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
let admin = store.setup_hosting_admin("admin", true).unwrap();
|
||||
let new_user = hosting_user("visitor");
|
||||
let target = PasskeyRegistrationTarget::NewAccount {
|
||||
user: new_user.clone(),
|
||||
};
|
||||
let credential = hosting_credential(&new_user, "visitor-key");
|
||||
let assert_denied = || {
|
||||
let error = store
|
||||
.register_verified_passkey(&target, &credential)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, Error::WorkspacePermissionDenied(ref message) if message.contains("self_registration_disabled"))
|
||||
);
|
||||
assert!(store.get_user(&new_user.user_id).unwrap().is_none());
|
||||
assert!(store.get_account(&new_user.account_id).unwrap().is_none());
|
||||
assert!(
|
||||
store
|
||||
.get_passkey_credential("visitor-key")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
};
|
||||
assert_denied();
|
||||
store
|
||||
.set_hosting_self_registration_enabled(&hosting_actor(&admin.user), true)
|
||||
.unwrap();
|
||||
// Simulate an enrollment allocated while enabled, then stopped before completion.
|
||||
store
|
||||
.set_hosting_self_registration_enabled(&hosting_actor(&admin.user), false)
|
||||
.unwrap();
|
||||
assert_denied();
|
||||
let token_hash = crate::auth::token_hash(&admin.initial_bearer.unwrap().access_token);
|
||||
let addition = PasskeyRegistrationTarget::Addition {
|
||||
user: admin.user.clone(),
|
||||
authentication: PasskeyAuthenticationBinding::ApiToken(token_hash.clone()),
|
||||
};
|
||||
store
|
||||
.register_verified_passkey(&addition, &hosting_credential(&admin.user, "admin-key"))
|
||||
.unwrap();
|
||||
assert!(store.get_passkey_credential("admin-key").unwrap().is_some());
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE api_tokens SET revoked_at=?1 WHERE token_hash=?2",
|
||||
params![crate::auth::now_rfc3339(), token_hash],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.register_verified_passkey(
|
||||
&addition,
|
||||
&hosting_credential(&admin.user, "revoked-key")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
store
|
||||
.set_hosting_self_registration_enabled(&hosting_actor(&admin.user), true)
|
||||
.unwrap();
|
||||
store
|
||||
.register_verified_passkey(&target, &credential)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.get_user(&new_user.user_id).unwrap(),
|
||||
Some(new_user.clone())
|
||||
);
|
||||
assert!(!store.is_hosting_admin(&new_user.user_id).unwrap());
|
||||
// A duplicate credential fails after account/user insertion and must roll both back.
|
||||
let failed_user = hosting_user("failed");
|
||||
assert!(
|
||||
store
|
||||
.register_verified_passkey(
|
||||
&PasskeyRegistrationTarget::NewAccount {
|
||||
user: failed_user.clone()
|
||||
},
|
||||
&hosting_credential(&failed_user, "visitor-key")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(store.get_user(&failed_user.user_id).unwrap().is_none());
|
||||
assert!(
|
||||
store
|
||||
.get_account(&failed_user.account_id)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
include!("schema_equivalence_tests.rs");
|
||||
include!("ticket_tag_upgrade_tests.rs");
|
||||
include!("ticket_post_upgrade_tests.rs");
|
||||
@@ -17840,6 +18395,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
WorkspaceSchemaMigrationStep {version:96, name:"Backend Job common notification routing".into()},
|
||||
WorkspaceSchemaMigrationStep {version:97, name:"MR notifications and authenticated implementation results".into()},
|
||||
WorkspaceSchemaMigrationStep {version:98, name:"Assignment-independent implementation reports".into()},
|
||||
WorkspaceSchemaMigrationStep {version:99, name:"Explicit hosting administrators and self-registration policy".into()},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -17948,6 +18504,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(96, "Backend Job common notification routing".to_string()),
|
||||
(97, "MR notifications and authenticated implementation results".to_string()),
|
||||
(98, "Assignment-independent implementation reports".to_string()),
|
||||
(99, "Explicit hosting administrators and self-registration policy".to_string()),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -20570,7 +21127,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(95, "Common notification routing and durable Worker delivery".into()),
|
||||
(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 unchanged.iter().zip(before.iter()) {
|
||||
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user