diff --git a/Cargo.lock b/Cargo.lock index 73ff75f..a33eac1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1317,6 +1317,7 @@ dependencies = [ "async-trait", "axum", "axum-test", + "chrono", "figment", "picloud-executor-core", "picloud-manager-core", @@ -1331,6 +1332,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/crates/picloud/Cargo.toml b/crates/picloud/Cargo.toml index 3d351f2..c29598c 100644 --- a/crates/picloud/Cargo.toml +++ b/crates/picloud/Cargo.toml @@ -39,3 +39,5 @@ figment.workspace = true axum-test = "17" serde.workspace = true serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true diff --git a/crates/picloud/tests/authz.rs b/crates/picloud/tests/authz.rs new file mode 100644 index 0000000..2718ee0 --- /dev/null +++ b/crates/picloud/tests/authz.rs @@ -0,0 +1,614 @@ +//! Phase 3.5 authorization end-to-end tests. +//! +//! Covers the 11 scenarios from `lay-foundations-for-snazzy-truffle.md` +//! step 9: +//! +//! 1. Bootstrap admin promotes to owner. +//! 2. Owner access matrix on a sample app. +//! 3. Admin access matrix. +//! 4. Member access matrix. +//! 5. Bearer (pic_) + cookie produce the same Principal. +//! 6. Scope intersection: a script:read-only key cannot write. +//! 7. Bound key cannot escape its app. +//! 8. Member listing isolation (apps + scripts). +//! 9. Deactivation revokes API keys. +//! 10. Mint rejects bound key with `instance:*` scope. +//! 11. `list_active_owners` returns the expected set under the seed +//! that the startup warning is built from (we don't capture the +//! log line itself — the data source is the testable surface). +//! +//! Same harness as `tests/api.rs`: `#[sqlx::test]` against a real +//! Postgres, `TestServer` over the in-process app. We do NOT bake a +//! token into the default headers here — each test wires its own +//! credential per request to exercise the cookie / Bearer split. + +#![allow(clippy::needless_pass_by_value)] + +use std::sync::Arc; + +use axum_test::TestServer; +use picloud_manager_core::{ + auth::hash_password, AdminUserRepository, ApiKeyRepository, AppMembersRepository, + PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppMembersRepository, +}; +use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole}; +use serde_json::{json, Value}; +use sqlx::PgPool; + +// ---------------------------------------------------------------------------- +// Harness +// ---------------------------------------------------------------------------- + +struct Seeded { + server: TestServer, + pool: PgPool, + /// Bootstrap admin — Owner, password "owner-pw". + owner: AdminUserId, + /// Default app id, slug "default" (seeded by 0005 migration). + default_app: AppId, +} + +async fn boot(pool: PgPool) -> Seeded { + let auth = picloud::AuthDeps::from_pool(pool.clone()); + let hash = hash_password("owner-pw").expect("hash"); + let owner = auth + .users + .create("owner", &hash, InstanceRole::Owner) + .await + .expect("seed owner"); + + let app = picloud::build_app(pool.clone(), auth).await.expect("build_app"); + let server = TestServer::new(app).expect("TestServer"); + + // Default app id (seeded by migration 0005). + let resp = server + .post("/api/v1/admin/auth/login") + .json(&json!({ "username": "owner", "password": "owner-pw" })) + .await; + resp.assert_status_ok(); + let token = resp.json::()["token"] + .as_str() + .expect("login token") + .to_string(); + + let app_resp = server + .get("/api/v1/admin/apps/default") + .add_header("authorization", format!("Bearer {token}")) + .await; + app_resp.assert_status_ok(); + let app_id: uuid::Uuid = app_resp.json::()["id"] + .as_str() + .expect("app id") + .parse() + .expect("uuid"); + + Seeded { + server, + pool, + owner: owner.id, + default_app: app_id.into(), + } +} + +/// Mint a session for an existing admin via the login endpoint and +/// return the raw token. Lets tests build a per-role credential +/// without baking it into the default headers. +async fn login_token(server: &TestServer, username: &str, password: &str) -> String { + let r = server + .post("/api/v1/admin/auth/login") + .json(&json!({ "username": username, "password": password })) + .await; + r.assert_status_ok(); + r.json::()["token"] + .as_str() + .expect("token in login response") + .to_string() +} + +/// Direct-DB seed (bypassing the API) for users we want to construct +/// at arbitrary roles. The API enforces "owners only create owners" +/// which is correct production behavior but inconvenient for test +/// fixtures. +async fn seed_user(pool: &PgPool, username: &str, password: &str, role: InstanceRole) -> AdminUserId { + let repo = PostgresAdminUserRepository::new(pool.clone()); + let hash = hash_password(password).expect("hash"); + repo.create(username, &hash, role).await.expect("seed user").id +} + +async fn grant_membership(pool: &PgPool, user: AdminUserId, app: AppId, role: AppRole) { + let repo = PostgresAppMembersRepository::new(pool.clone()); + repo.upsert(app, user, role).await.expect("grant membership"); +} + +async fn create_script_via_api(server: &TestServer, token: &str, app_id: AppId, name: &str) -> Value { + let r = server + .post("/api/v1/admin/scripts") + .add_header("authorization", format!("Bearer {token}")) + .json(&json!({ + "app_id": app_id.to_string(), + "name": name, + "source": "fn main() { #{ statusCode: 200 } }", + })) + .await; + r.assert_status(axum::http::StatusCode::CREATED); + r.json() +} + +/// Mint an API key for the caller — wraps POST /api-keys. +async fn mint_key( + server: &TestServer, + cred_token: &str, + body: Value, +) -> axum_test::TestResponse { + server + .post("/api/v1/admin/api-keys") + .add_header("authorization", format!("Bearer {cred_token}")) + .json(&body) + .await +} + +// ---------------------------------------------------------------------------- +// 1. Bootstrap admin → owner +// ---------------------------------------------------------------------------- + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[sqlx::test(migrations = "../manager-core/migrations")] +async fn bootstrap_admin_is_owner(pool: PgPool) { + let s = boot(pool).await; + let token = login_token(&s.server, "owner", "owner-pw").await; + let me = s + .server + .get("/api/v1/admin/auth/me") + .add_header("authorization", format!("Bearer {token}")) + .await; + me.assert_status_ok(); + let listing = s + .server + .get("/api/v1/admin/admins") + .add_header("authorization", format!("Bearer {token}")) + .await; + listing.assert_status_ok(); + let arr: Value = listing.json(); + let row = arr + .as_array() + .and_then(|v| v.iter().find(|u| u["username"] == "owner")) + .expect("owner row"); + assert_eq!(row["instance_role"], "owner"); +} + +// ---------------------------------------------------------------------------- +// 2 / 3 / 4. Role access matrices on a sample app +// ---------------------------------------------------------------------------- + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[sqlx::test(migrations = "../manager-core/migrations")] +async fn owner_access_matrix(pool: PgPool) { + let s = boot(pool.clone()).await; + let token = login_token(&s.server, "owner", "owner-pw").await; + + // Read apps / scripts. + s.server + .get("/api/v1/admin/apps/default") + .add_header("authorization", format!("Bearer {token}")) + .await + .assert_status_ok(); + + // Create a script — AppWriteScript. + let script = create_script_via_api(&s.server, &token, s.default_app, "owner-test").await; + let sid = script["id"].as_str().unwrap(); + + // Read it back — AppRead. + s.server + .get(&format!("/api/v1/admin/scripts/{sid}")) + .add_header("authorization", format!("Bearer {token}")) + .await + .assert_status_ok(); + + // Manage users — InstanceManageUsers. + s.server + .get("/api/v1/admin/admins") + .add_header("authorization", format!("Bearer {token}")) + .await + .assert_status_ok(); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[sqlx::test(migrations = "../manager-core/migrations")] +async fn admin_can_manage_users_but_not_app_admin_settings(pool: PgPool) { + let s = boot(pool.clone()).await; + seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await; + let token = login_token(&s.server, "alice", "alice-pw").await; + + // Allowed: list admins (InstanceManageUsers). + s.server + .get("/api/v1/admin/admins") + .add_header("authorization", format!("Bearer {token}")) + .await + .assert_status_ok(); + + // Allowed: read default app (admin is implicit editor everywhere). + s.server + .get("/api/v1/admin/apps/default") + .add_header("authorization", format!("Bearer {token}")) + .await + .assert_status_ok(); + + // Allowed: write scripts (implicit editor). + let script = create_script_via_api(&s.server, &token, s.default_app, "admin-write").await; + assert!(script["id"].is_string()); + + // Denied: delete the default app (AppAdmin only). + let denied = s + .server + .delete("/api/v1/admin/apps/default") + .add_header("authorization", format!("Bearer {token}")) + .await; + assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[sqlx::test(migrations = "../manager-core/migrations")] +async fn member_can_only_touch_apps_they_belong_to(pool: PgPool) { + let s = boot(pool.clone()).await; + let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await; + grant_membership(&s.pool, bob, s.default_app, AppRole::Editor).await; + let token = login_token(&s.server, "bob", "bob-pw").await; + + // Allowed: read + write inside the default app. + s.server + .get("/api/v1/admin/apps/default") + .add_header("authorization", format!("Bearer {token}")) + .await + .assert_status_ok(); + let script = create_script_via_api(&s.server, &token, s.default_app, "member-write").await; + let sid = script["id"].as_str().unwrap(); + s.server + .get(&format!("/api/v1/admin/scripts/{sid}")) + .add_header("authorization", format!("Bearer {token}")) + .await + .assert_status_ok(); + + // Denied: create a *new* app (member cannot InstanceCreateApp). + let denied = s + .server + .post("/api/v1/admin/apps") + .add_header("authorization", format!("Bearer {token}")) + .json(&json!({ "slug": "other", "name": "Other" })) + .await; + assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN); + + // Denied: manage admins. + let denied = s + .server + .get("/api/v1/admin/admins") + .add_header("authorization", format!("Bearer {token}")) + .await; + assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN); +} + +// ---------------------------------------------------------------------------- +// 5. Bearer pic_ + cookie produce the same Principal +// ---------------------------------------------------------------------------- + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[sqlx::test(migrations = "../manager-core/migrations")] +async fn bearer_and_cookie_produce_same_principal(pool: PgPool) { + let s = boot(pool).await; + let session_token = login_token(&s.server, "owner", "owner-pw").await; + + // Mint a no-binding owner key covering script:read. + let mint = mint_key( + &s.server, + &session_token, + json!({ + "name": "owner-readonly", + "scopes": ["script:read"], + }), + ) + .await; + mint.assert_status(axum::http::StatusCode::CREATED); + let raw_token = mint.json::()["raw_token"] + .as_str() + .expect("raw token") + .to_string(); + assert!(raw_token.starts_with("pic_")); + + // /me through the cookie/session path. + let via_session = s + .server + .get("/api/v1/admin/auth/me") + .add_header("authorization", format!("Bearer {session_token}")) + .await; + via_session.assert_status_ok(); + + // /me through the pic_ path — same user_id. + let via_key = s + .server + .get("/api/v1/admin/auth/me") + .add_header("authorization", format!("Bearer {raw_token}")) + .await; + via_key.assert_status_ok(); + + assert_eq!(via_session.json::()["id"], via_key.json::()["id"]); + assert_eq!( + via_session.json::()["username"], + via_key.json::()["username"] + ); +} + +// ---------------------------------------------------------------------------- +// 6. Scope intersection — read-only key cannot write +// ---------------------------------------------------------------------------- + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[sqlx::test(migrations = "../manager-core/migrations")] +async fn read_only_key_cannot_write_scripts(pool: PgPool) { + let s = boot(pool).await; + let session_token = login_token(&s.server, "owner", "owner-pw").await; + let mint = mint_key( + &s.server, + &session_token, + json!({ "name": "ro", "scopes": ["script:read"] }), + ) + .await; + mint.assert_status(axum::http::StatusCode::CREATED); + let raw = mint.json::()["raw_token"].as_str().unwrap().to_string(); + + let denied = s + .server + .post("/api/v1/admin/scripts") + .add_header("authorization", format!("Bearer {raw}")) + .json(&json!({ + "app_id": s.default_app.to_string(), + "name": "would-write", + "source": "fn main() { #{ statusCode: 200 } }", + })) + .await; + assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN); +} + +// ---------------------------------------------------------------------------- +// 7. Bound key cannot escape its app +// ---------------------------------------------------------------------------- + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[sqlx::test(migrations = "../manager-core/migrations")] +async fn bound_key_cannot_escape_its_app(pool: PgPool) { + let s = boot(pool.clone()).await; + let session_token = login_token(&s.server, "owner", "owner-pw").await; + + // Create a second app via the API (owner can InstanceCreateApp). + let other = s + .server + .post("/api/v1/admin/apps") + .add_header("authorization", format!("Bearer {session_token}")) + .json(&json!({ "slug": "other", "name": "Other" })) + .await; + other.assert_status(axum::http::StatusCode::CREATED); + let other_id = other.json::()["id"].as_str().unwrap().to_string(); + + // Mint a key bound to the default app with script:write. + let mint = mint_key( + &s.server, + &session_token, + json!({ + "name": "default-only", + "scopes": ["script:write"], + "app_id": s.default_app.to_string(), + }), + ) + .await; + mint.assert_status(axum::http::StatusCode::CREATED); + let raw = mint.json::()["raw_token"].as_str().unwrap().to_string(); + + // Writing into the bound app: allowed. + let ok = s + .server + .post("/api/v1/admin/scripts") + .add_header("authorization", format!("Bearer {raw}")) + .json(&json!({ + "app_id": s.default_app.to_string(), + "name": "bound-ok", + "source": "fn main() { #{ statusCode: 200 } }", + })) + .await; + ok.assert_status(axum::http::StatusCode::CREATED); + + // Writing into the *other* app: forbidden. + let denied = s + .server + .post("/api/v1/admin/scripts") + .add_header("authorization", format!("Bearer {raw}")) + .json(&json!({ + "app_id": other_id, + "name": "escape-attempt", + "source": "fn main() { #{ statusCode: 200 } }", + })) + .await; + assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN); +} + +// ---------------------------------------------------------------------------- +// 8. Member listing isolation +// ---------------------------------------------------------------------------- + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[sqlx::test(migrations = "../manager-core/migrations")] +async fn member_list_endpoints_filter_at_sql(pool: PgPool) { + let s = boot(pool.clone()).await; + let owner_token = login_token(&s.server, "owner", "owner-pw").await; + + // Owner creates a second app + script-in-that-app. + let other = s + .server + .post("/api/v1/admin/apps") + .add_header("authorization", format!("Bearer {owner_token}")) + .json(&json!({ "slug": "secret", "name": "Secret" })) + .await; + other.assert_status(axum::http::StatusCode::CREATED); + let other_id: uuid::Uuid = other.json::()["id"] + .as_str() + .unwrap() + .parse() + .unwrap(); + let other_app: AppId = other_id.into(); + create_script_via_api(&s.server, &owner_token, other_app, "secret-script").await; + create_script_via_api(&s.server, &owner_token, s.default_app, "default-script").await; + + // Carol is a member of the default app only. + let carol = seed_user(&s.pool, "carol", "carol-pw", InstanceRole::Member).await; + grant_membership(&s.pool, carol, s.default_app, AppRole::Viewer).await; + let carol_token = login_token(&s.server, "carol", "carol-pw").await; + + let apps = s + .server + .get("/api/v1/admin/apps") + .add_header("authorization", format!("Bearer {carol_token}")) + .await; + apps.assert_status_ok(); + let apps_body: Value = apps.json(); + let app_slugs: Vec = apps_body + .as_array() + .unwrap() + .iter() + .map(|a| a["slug"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(app_slugs, vec!["default"], "member must see only their apps"); + + let scripts = s + .server + .get("/api/v1/admin/scripts") + .add_header("authorization", format!("Bearer {carol_token}")) + .await; + scripts.assert_status_ok(); + let scripts_body: Value = scripts.json(); + let names: Vec = scripts_body + .as_array() + .unwrap() + .iter() + .map(|s| s["name"].as_str().unwrap().to_string()) + .collect(); + assert!( + names.iter().any(|n| n == "default-script") + && !names.iter().any(|n| n == "secret-script"), + "member listing leaked another app's script: {names:?}" + ); +} + +// ---------------------------------------------------------------------------- +// 9. Deactivation revokes API keys +// ---------------------------------------------------------------------------- + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[sqlx::test(migrations = "../manager-core/migrations")] +async fn deactivating_user_revokes_their_api_keys(pool: PgPool) { + let s = boot(pool.clone()).await; + let owner_token = login_token(&s.server, "owner", "owner-pw").await; + + // A second user — admin so they can mint a key for themselves. + let dave_id = seed_user(&s.pool, "dave", "dave-pw", InstanceRole::Admin).await; + let dave_token = login_token(&s.server, "dave", "dave-pw").await; + let mint = mint_key( + &s.server, + &dave_token, + json!({ "name": "dave-key", "scopes": ["script:read"] }), + ) + .await; + mint.assert_status(axum::http::StatusCode::CREATED); + let raw = mint.json::()["raw_token"].as_str().unwrap().to_string(); + + // Key works. + let before = s + .server + .get("/api/v1/admin/auth/me") + .add_header("authorization", format!("Bearer {raw}")) + .await; + before.assert_status_ok(); + + // Owner deactivates Dave. + let patch = s + .server + .patch(&format!("/api/v1/admin/admins/{dave_id}")) + .add_header("authorization", format!("Bearer {owner_token}")) + .json(&json!({ "is_active": false })) + .await; + patch.assert_status_ok(); + + // Key now rejects with 401. + let after = s + .server + .get("/api/v1/admin/auth/me") + .add_header("authorization", format!("Bearer {raw}")) + .await; + assert_eq!(after.status_code(), axum::http::StatusCode::UNAUTHORIZED); + + // Cross-check via the repo: the row's expires_at is set in the past. + let repo = PostgresApiKeyRepository::new(s.pool.clone()); + let rows = repo.list_for_user(dave_id).await.expect("list keys"); + assert!( + rows.iter().all(|r| r.expires_at.is_some()), + "every key must have an expiry after deactivation" + ); + assert!(rows.iter().all(|r| r.expires_at.unwrap() <= chrono::Utc::now())); +} + +// ---------------------------------------------------------------------------- +// 10. Mint rejects bound key + instance scope +// ---------------------------------------------------------------------------- + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[sqlx::test(migrations = "../manager-core/migrations")] +async fn bound_key_with_instance_scope_is_rejected(pool: PgPool) { + let s = boot(pool).await; + let token = login_token(&s.server, "owner", "owner-pw").await; + let r = s + .server + .post("/api/v1/admin/api-keys") + .add_header("authorization", format!("Bearer {token}")) + .json(&json!({ + "name": "irreconcilable", + "scopes": ["instance:admin"], + "app_id": s.default_app.to_string(), + })) + .await; + assert_eq!(r.status_code(), axum::http::StatusCode::UNPROCESSABLE_ENTITY); + let body: Value = r.json(); + assert!( + body["error"].as_str().unwrap().contains("bound"), + "error body should explain the conflict, got {body}" + ); +} + +// ---------------------------------------------------------------------------- +// 11. Multi-owner detection — data-source for the startup warning +// ---------------------------------------------------------------------------- + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[sqlx::test(migrations = "../manager-core/migrations")] +async fn list_active_owners_drives_the_multi_owner_warning(pool: PgPool) { + let s = boot(pool.clone()).await; + + // Seed a second owner directly so we exercise the + // multi-owner condition. + seed_user(&s.pool, "owner2", "pw", InstanceRole::Owner).await; + seed_user(&s.pool, "admin1", "pw", InstanceRole::Admin).await; + + let users = Arc::new(PostgresAdminUserRepository::new(s.pool.clone())); + let owners = users.list_active_owners().await.expect("list owners"); + let names: Vec<&str> = owners.iter().map(|o| o.username.as_str()).collect(); + assert!(names.contains(&"owner")); + assert!(names.contains(&"owner2")); + assert!(!names.contains(&"admin1")); + assert_eq!( + owners.len(), + 2, + "list_active_owners must filter strictly by instance_role" + ); + + // count_other_active_owners powers the last-owner guard. + let remaining = users + .count_other_active_owners(s.owner) + .await + .expect("count"); + assert_eq!(remaining, 1, "one other owner should remain (owner2)"); +}