style: cargo fmt across Phase 3.5 changes
Pure formatting pass — no behavior changes. Catches the line-wrapping drift across the new authz / api_keys / middleware / handler edits that piled up during the implementation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -383,8 +383,9 @@ impl TryFrom<AdminUserRecord> for AdminUserRow {
|
|||||||
id: r.id.into(),
|
id: r.id.into(),
|
||||||
username: r.username,
|
username: r.username,
|
||||||
is_active: r.is_active,
|
is_active: r.is_active,
|
||||||
instance_role: InstanceRole::from_db_str(&r.instance_role)
|
instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
|
||||||
.ok_or(AdminUserRepositoryError::InvalidInstanceRole(r.instance_role))?,
|
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
|
||||||
|
)?,
|
||||||
email: r.email,
|
email: r.email,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
updated_at: r.updated_at,
|
updated_at: r.updated_at,
|
||||||
@@ -410,8 +411,9 @@ impl TryFrom<AdminCredsRecord> for AdminUserCredentials {
|
|||||||
username: r.username,
|
username: r.username,
|
||||||
password_hash: r.password_hash,
|
password_hash: r.password_hash,
|
||||||
is_active: r.is_active,
|
is_active: r.is_active,
|
||||||
instance_role: InstanceRole::from_db_str(&r.instance_role)
|
instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
|
||||||
.ok_or(AdminUserRepositoryError::InvalidInstanceRole(r.instance_role))?,
|
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
|
||||||
|
)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,8 +162,7 @@ async fn create_admin(
|
|||||||
// owner — admin cannot self-elevate (or elevate someone else)
|
// owner — admin cannot self-elevate (or elevate someone else)
|
||||||
// beyond their own ceiling. Owner-creation by env-var bootstrap
|
// beyond their own ceiling. Owner-creation by env-var bootstrap
|
||||||
// bypasses this path.
|
// bypasses this path.
|
||||||
if input.instance_role == InstanceRole::Owner
|
if input.instance_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner
|
||||||
&& principal.instance_role != InstanceRole::Owner
|
|
||||||
{
|
{
|
||||||
return Err(AdminApiError::CannotEscalate);
|
return Err(AdminApiError::CannotEscalate);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,10 +112,8 @@ pub trait ApiKeyRepository: Send + Sync {
|
|||||||
/// into `set_active(false)` so deactivation invalidates both
|
/// into `set_active(false)` so deactivation invalidates both
|
||||||
/// sessions (already done by `AdminSessionRepository::delete_for_user`)
|
/// sessions (already done by `AdminSessionRepository::delete_for_user`)
|
||||||
/// and bearer keys at the same moment.
|
/// and bearer keys at the same moment.
|
||||||
async fn expire_all_for_user(
|
async fn expire_all_for_user(&self, user_id: AdminUserId)
|
||||||
&self,
|
-> Result<u64, ApiKeyRepositoryError>;
|
||||||
user_id: AdminUserId,
|
|
||||||
) -> Result<u64, ApiKeyRepositoryError>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PostgresApiKeyRepository {
|
pub struct PostgresApiKeyRepository {
|
||||||
@@ -132,7 +130,8 @@ impl PostgresApiKeyRepository {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ApiKeyRepository for PostgresApiKeyRepository {
|
impl ApiKeyRepository for PostgresApiKeyRepository {
|
||||||
async fn create(&self, key: NewApiKey) -> Result<ApiKeyRow, ApiKeyRepositoryError> {
|
async fn create(&self, key: NewApiKey) -> Result<ApiKeyRow, ApiKeyRepositoryError> {
|
||||||
let scope_strings: Vec<String> = key.scopes.iter().map(|s| s.as_str().to_string()).collect();
|
let scope_strings: Vec<String> =
|
||||||
|
key.scopes.iter().map(|s| s.as_str().to_string()).collect();
|
||||||
let row = sqlx::query_as::<_, ApiKeyRecord>(
|
let row = sqlx::query_as::<_, ApiKeyRecord>(
|
||||||
"INSERT INTO api_keys \
|
"INSERT INTO api_keys \
|
||||||
(user_id, hash, prefix, name, scopes, app_id, expires_at) \
|
(user_id, hash, prefix, name, scopes, app_id, expires_at) \
|
||||||
|
|||||||
@@ -19,10 +19,7 @@ pub enum AppMembersRepositoryError {
|
|||||||
Db(#[from] sqlx::Error),
|
Db(#[from] sqlx::Error),
|
||||||
|
|
||||||
#[error("membership row not found: app={app_id}, user={user_id}")]
|
#[error("membership row not found: app={app_id}, user={user_id}")]
|
||||||
NotFound {
|
NotFound { app_id: AppId, user_id: AdminUserId },
|
||||||
app_id: AppId,
|
|
||||||
user_id: AdminUserId,
|
|
||||||
},
|
|
||||||
|
|
||||||
#[error("invalid app_role stored in DB: {0}")]
|
#[error("invalid app_role stored in DB: {0}")]
|
||||||
InvalidRole(String),
|
InvalidRole(String),
|
||||||
|
|||||||
@@ -27,10 +27,7 @@ pub trait AppRepository: Send + Sync {
|
|||||||
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||||
/// Only apps the user has an `app_members` row for. Drives the
|
/// Only apps the user has an `app_members` row for. Drives the
|
||||||
/// membership-filtered `GET /admin/apps` for `member` callers.
|
/// membership-filtered `GET /admin/apps` for `member` callers.
|
||||||
async fn list_for_user(
|
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||||
&self,
|
|
||||||
user_id: AdminUserId,
|
|
||||||
) -> Result<Vec<App>, ScriptRepositoryError>;
|
|
||||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
async fn get_by_slug_or_history(
|
async fn get_by_slug_or_history(
|
||||||
@@ -100,10 +97,7 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_for_user(
|
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
&self,
|
|
||||||
user_id: AdminUserId,
|
|
||||||
) -> Result<Vec<App>, ScriptRepositoryError> {
|
|
||||||
let rows = sqlx::query_as::<_, AppRow>(
|
let rows = sqlx::query_as::<_, AppRow>(
|
||||||
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||||
FROM apps a \
|
FROM apps a \
|
||||||
|
|||||||
@@ -223,6 +223,9 @@ mod tests {
|
|||||||
let b = generate_api_key().expect("mint b");
|
let b = generate_api_key().expect("mint b");
|
||||||
assert_ne!(a.raw, b.raw);
|
assert_ne!(a.raw, b.raw);
|
||||||
assert_ne!(a.hash, b.hash);
|
assert_ne!(a.hash, b.hash);
|
||||||
assert_ne!(a.prefix, b.prefix, "32 random bytes → prefix collision is negligible");
|
assert_ne!(
|
||||||
|
a.prefix, b.prefix,
|
||||||
|
"32 random bytes → prefix collision is negligible"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,10 @@ async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response
|
|||||||
(StatusCode::NO_CONTENT, headers).into_response()
|
(StatusCode::NO_CONTENT, headers).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn me(State(state): State<AuthState>, Extension(principal): Extension<Principal>) -> Response {
|
async fn me(
|
||||||
|
State(state): State<AuthState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
) -> Response {
|
||||||
// /me consumes the resolved Principal directly; we re-fetch the
|
// /me consumes the resolved Principal directly; we re-fetch the
|
||||||
// user row only to surface a fresh username (it can change via
|
// user row only to surface a fresh username (it can change via
|
||||||
// PATCH while a session/key is still valid).
|
// PATCH while a session/key is still valid).
|
||||||
|
|||||||
@@ -272,7 +272,9 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn populated_db_is_noop() {
|
async fn populated_db_is_noop() {
|
||||||
let repo = InMemoryRepo::default();
|
let repo = InMemoryRepo::default();
|
||||||
repo.create("seeded", "x", InstanceRole::Owner).await.unwrap();
|
repo.create("seeded", "x", InstanceRole::Owner)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let env = BootstrapEnv {
|
let env = BootstrapEnv {
|
||||||
username: Some("alice".into()),
|
username: Some("alice".into()),
|
||||||
password: Some("supersecret".into()),
|
password: Some("supersecret".into()),
|
||||||
|
|||||||
@@ -96,11 +96,7 @@ pub async fn require_authenticated(
|
|||||||
/// `require_admin` keeps working without an immediate rename. New
|
/// `require_admin` keeps working without an immediate rename. New
|
||||||
/// wiring should call `require_authenticated`.
|
/// wiring should call `require_authenticated`.
|
||||||
#[deprecated(note = "renamed to require_authenticated")]
|
#[deprecated(note = "renamed to require_authenticated")]
|
||||||
pub async fn require_admin(
|
pub async fn require_admin(state: State<AuthState>, req: Request<Body>, next: Next) -> Response {
|
||||||
state: State<AuthState>,
|
|
||||||
req: Request<Body>,
|
|
||||||
next: Next,
|
|
||||||
) -> Response {
|
|
||||||
require_authenticated(state, req, next).await
|
require_authenticated(state, req, next).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,10 +160,7 @@ async fn verify_session(
|
|||||||
/// against the full `rest`. At most one match is expected; multiple
|
/// against the full `rest`. At most one match is expected; multiple
|
||||||
/// candidates with the same prefix is statistically negligible but
|
/// candidates with the same prefix is statistically negligible but
|
||||||
/// handled correctly (verify each, take the first match).
|
/// handled correctly (verify each, take the first match).
|
||||||
async fn verify_api_key(
|
async fn verify_api_key(state: &AuthState, rest: &str) -> Result<Option<Principal>, InternalError> {
|
||||||
state: &AuthState,
|
|
||||||
rest: &str,
|
|
||||||
) -> Result<Option<Principal>, InternalError> {
|
|
||||||
if rest.len() <= API_KEY_PREFIX_LEN {
|
if rest.len() <= API_KEY_PREFIX_LEN {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -224,7 +217,10 @@ async fn username_for(state: &AuthState, id: AdminUserId) -> Option<String> {
|
|||||||
Ok(Some(u)) => Some(u.username),
|
Ok(Some(u)) => Some(u.username),
|
||||||
Ok(None) => None,
|
Ok(None) => None,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::warn!(?err, "username lookup for AuthedAdmin failed; skipping legacy ext");
|
tracing::warn!(
|
||||||
|
?err,
|
||||||
|
"username lookup for AuthedAdmin failed; skipping legacy ext"
|
||||||
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ impl Capability {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn app_id(self) -> Option<AppId> {
|
pub const fn app_id(self) -> Option<AppId> {
|
||||||
match self {
|
match self {
|
||||||
Self::InstanceCreateApp
|
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||||
| Self::InstanceManageUsers
|
None
|
||||||
| Self::InstanceManageSettings => None,
|
}
|
||||||
Self::AppRead(id)
|
Self::AppRead(id)
|
||||||
| Self::AppWriteScript(id)
|
| Self::AppWriteScript(id)
|
||||||
| Self::AppWriteRoute(id)
|
| Self::AppWriteRoute(id)
|
||||||
@@ -85,9 +85,9 @@ impl Capability {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn required_scope(self) -> Scope {
|
pub const fn required_scope(self) -> Scope {
|
||||||
match self {
|
match self {
|
||||||
Self::InstanceCreateApp
|
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||||
| Self::InstanceManageUsers
|
Scope::InstanceAdmin
|
||||||
| Self::InstanceManageSettings => Scope::InstanceAdmin,
|
}
|
||||||
Self::AppRead(_) => Scope::ScriptRead,
|
Self::AppRead(_) => Scope::ScriptRead,
|
||||||
Self::AppWriteScript(_) => Scope::ScriptWrite,
|
Self::AppWriteScript(_) => Scope::ScriptWrite,
|
||||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||||
@@ -314,7 +314,12 @@ mod tests {
|
|||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
) -> Result<Option<AppRole>, AuthzError> {
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
Ok(self.memberships.lock().await.get(&(user_id, app_id)).copied())
|
Ok(self
|
||||||
|
.memberships
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&(user_id, app_id))
|
||||||
|
.copied())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,25 +366,35 @@ mod tests {
|
|||||||
Decision::Allow,
|
Decision::Allow,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
can(&repo, &p, Capability::InstanceManageUsers).await.unwrap(),
|
can(&repo, &p, Capability::InstanceManageUsers)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
Decision::Allow,
|
Decision::Allow,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
can(&repo, &p, Capability::InstanceManageSettings).await.unwrap(),
|
can(&repo, &p, Capability::InstanceManageSettings)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
Decision::Deny,
|
Decision::Deny,
|
||||||
);
|
);
|
||||||
// Editor-like grants succeed
|
// Editor-like grants succeed
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
can(&repo, &p, Capability::AppWriteScript(app)).await.unwrap(),
|
can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
Decision::Allow,
|
Decision::Allow,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
can(&repo, &p, Capability::AppWriteRoute(app)).await.unwrap(),
|
can(&repo, &p, Capability::AppWriteRoute(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
Decision::Allow,
|
Decision::Allow,
|
||||||
);
|
);
|
||||||
// App-admin grants do not
|
// App-admin grants do not
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
can(&repo, &p, Capability::AppManageDomains(app)).await.unwrap(),
|
can(&repo, &p, Capability::AppManageDomains(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
Decision::Deny,
|
Decision::Deny,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -418,10 +433,18 @@ mod tests {
|
|||||||
let app = AppId::new();
|
let app = AppId::new();
|
||||||
repo.grant(p.user_id, app, AppRole::Viewer).await;
|
repo.grant(p.user_id, app, AppRole::Viewer).await;
|
||||||
|
|
||||||
assert!(can(&repo, &p, Capability::AppRead(app)).await.unwrap().is_allow());
|
assert!(can(&repo, &p, Capability::AppRead(app))
|
||||||
assert!(can(&repo, &p, Capability::AppLogRead(app)).await.unwrap().is_allow());
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert!(can(&repo, &p, Capability::AppLogRead(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
can(&repo, &p, Capability::AppWriteScript(app)).await.unwrap(),
|
can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
Decision::Deny
|
Decision::Deny
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -437,8 +460,14 @@ mod tests {
|
|||||||
let app = AppId::new();
|
let app = AppId::new();
|
||||||
repo.grant(p.user_id, app, AppRole::Editor).await;
|
repo.grant(p.user_id, app, AppRole::Editor).await;
|
||||||
|
|
||||||
assert!(can(&repo, &p, Capability::AppWriteScript(app)).await.unwrap().is_allow());
|
assert!(can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
assert!(can(&repo, &p, Capability::AppWriteRoute(app)).await.unwrap().is_allow());
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert!(can(&repo, &p, Capability::AppWriteRoute(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||||
Decision::Deny
|
Decision::Deny
|
||||||
@@ -452,12 +481,20 @@ mod tests {
|
|||||||
let app = AppId::new();
|
let app = AppId::new();
|
||||||
repo.grant(p.user_id, app, AppRole::AppAdmin).await;
|
repo.grant(p.user_id, app, AppRole::AppAdmin).await;
|
||||||
|
|
||||||
assert!(can(&repo, &p, Capability::AppAdmin(app)).await.unwrap().is_allow());
|
assert!(can(&repo, &p, Capability::AppAdmin(app))
|
||||||
assert!(can(&repo, &p, Capability::AppManageDomains(app)).await.unwrap().is_allow());
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert!(can(&repo, &p, Capability::AppManageDomains(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
// Membership in App A does NOT grant access to App B
|
// Membership in App A does NOT grant access to App B
|
||||||
let other_app = AppId::new();
|
let other_app = AppId::new();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
can(&repo, &p, Capability::AppAdmin(other_app)).await.unwrap(),
|
can(&repo, &p, Capability::AppAdmin(other_app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
Decision::Deny
|
Decision::Deny
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -473,9 +510,14 @@ mod tests {
|
|||||||
scopes: Some(vec![Scope::ScriptRead]),
|
scopes: Some(vec![Scope::ScriptRead]),
|
||||||
app_binding: None,
|
app_binding: None,
|
||||||
};
|
};
|
||||||
assert!(can(&repo, &p, Capability::AppRead(app)).await.unwrap().is_allow());
|
assert!(can(&repo, &p, Capability::AppRead(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
can(&repo, &p, Capability::AppWriteScript(app)).await.unwrap(),
|
can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
Decision::Deny
|
Decision::Deny
|
||||||
);
|
);
|
||||||
// Even though the user is owner — the key's scope set is the
|
// Even though the user is owner — the key's scope set is the
|
||||||
@@ -502,7 +544,9 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.is_allow());
|
.is_allow());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
can(&repo, &p, Capability::AppWriteScript(other_app)).await.unwrap(),
|
can(&repo, &p, Capability::AppWriteScript(other_app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
Decision::Deny
|
Decision::Deny
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ use picloud_executor_core::{Engine, Limits};
|
|||||||
use picloud_manager_core::{
|
use picloud_manager_core::{
|
||||||
admin_router, admins_router, api_keys_router, apps_api, apps_router, auth_router,
|
admin_router, admins_router, api_keys_router, apps_api, apps_router, auth_router,
|
||||||
compile_routes, migrations, require_authenticated, route_admin_router, AdminSessionRepository,
|
compile_routes, migrations, require_authenticated, route_admin_router, AdminSessionRepository,
|
||||||
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository,
|
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
|
||||||
AppRepository, AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository,
|
AppDomainRepository, AppRepository, AppsState, AuthState, AuthzRepo,
|
||||||
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
|
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
|
||||||
PostgresAppMembersRepository, PostgresAppRepository, PostgresExecutionLogRepository,
|
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
|
||||||
PostgresExecutionLogSink, PostgresRouteRepository, PostgresScriptRepository, RepoResolver,
|
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
||||||
RouteAdminState, RouteRepository, SandboxCeiling,
|
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||||
use picloud_orchestrator_core::{
|
use picloud_orchestrator_core::{
|
||||||
@@ -164,9 +164,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
keys: auth.keys.clone(),
|
keys: auth.keys.clone(),
|
||||||
authz,
|
authz,
|
||||||
};
|
};
|
||||||
let api_keys_state = ApiKeysState {
|
let api_keys_state = ApiKeysState { keys: auth.keys };
|
||||||
keys: auth.keys,
|
|
||||||
};
|
|
||||||
|
|
||||||
// /admin/auth/login + /logout are unguarded by design (login is how
|
// /admin/auth/login + /logout are unguarded by design (login is how
|
||||||
// you get in). /admin/auth/me applies the middleware internally so
|
// you get in). /admin/auth/me applies the middleware internally so
|
||||||
|
|||||||
@@ -100,7 +100,10 @@ async fn warn_on_multi_owner_install(users: &dyn AdminUserRepository) {
|
|||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::warn!(?err, "could not count active owners for multi-owner startup check");
|
tracing::warn!(
|
||||||
|
?err,
|
||||||
|
"could not count active owners for multi-owner startup check"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ async fn boot(pool: PgPool) -> Seeded {
|
|||||||
.await
|
.await
|
||||||
.expect("seed owner");
|
.expect("seed owner");
|
||||||
|
|
||||||
let app = picloud::build_app(pool.clone(), auth).await.expect("build_app");
|
let app = picloud::build_app(pool.clone(), auth)
|
||||||
|
.await
|
||||||
|
.expect("build_app");
|
||||||
let server = TestServer::new(app).expect("TestServer");
|
let server = TestServer::new(app).expect("TestServer");
|
||||||
|
|
||||||
// Default app id (seeded by migration 0005).
|
// Default app id (seeded by migration 0005).
|
||||||
@@ -109,18 +111,33 @@ async fn login_token(server: &TestServer, username: &str, password: &str) -> Str
|
|||||||
/// at arbitrary roles. The API enforces "owners only create owners"
|
/// at arbitrary roles. The API enforces "owners only create owners"
|
||||||
/// which is correct production behavior but inconvenient for test
|
/// which is correct production behavior but inconvenient for test
|
||||||
/// fixtures.
|
/// fixtures.
|
||||||
async fn seed_user(pool: &PgPool, username: &str, password: &str, role: InstanceRole) -> AdminUserId {
|
async fn seed_user(
|
||||||
|
pool: &PgPool,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
role: InstanceRole,
|
||||||
|
) -> AdminUserId {
|
||||||
let repo = PostgresAdminUserRepository::new(pool.clone());
|
let repo = PostgresAdminUserRepository::new(pool.clone());
|
||||||
let hash = hash_password(password).expect("hash");
|
let hash = hash_password(password).expect("hash");
|
||||||
repo.create(username, &hash, role).await.expect("seed user").id
|
repo.create(username, &hash, role)
|
||||||
|
.await
|
||||||
|
.expect("seed user")
|
||||||
|
.id
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn grant_membership(pool: &PgPool, user: AdminUserId, app: AppId, role: AppRole) {
|
async fn grant_membership(pool: &PgPool, user: AdminUserId, app: AppId, role: AppRole) {
|
||||||
let repo = PostgresAppMembersRepository::new(pool.clone());
|
let repo = PostgresAppMembersRepository::new(pool.clone());
|
||||||
repo.upsert(app, user, role).await.expect("grant membership");
|
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 {
|
async fn create_script_via_api(
|
||||||
|
server: &TestServer,
|
||||||
|
token: &str,
|
||||||
|
app_id: AppId,
|
||||||
|
name: &str,
|
||||||
|
) -> Value {
|
||||||
let r = server
|
let r = server
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.add_header("authorization", format!("Bearer {token}"))
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
@@ -135,11 +152,7 @@ async fn create_script_via_api(server: &TestServer, token: &str, app_id: AppId,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Mint an API key for the caller — wraps POST /api-keys.
|
/// Mint an API key for the caller — wraps POST /api-keys.
|
||||||
async fn mint_key(
|
async fn mint_key(server: &TestServer, cred_token: &str, body: Value) -> axum_test::TestResponse {
|
||||||
server: &TestServer,
|
|
||||||
cred_token: &str,
|
|
||||||
body: Value,
|
|
||||||
) -> axum_test::TestResponse {
|
|
||||||
server
|
server
|
||||||
.post("/api/v1/admin/api-keys")
|
.post("/api/v1/admin/api-keys")
|
||||||
.add_header("authorization", format!("Bearer {cred_token}"))
|
.add_header("authorization", format!("Bearer {cred_token}"))
|
||||||
@@ -329,7 +342,10 @@ async fn bearer_and_cookie_produce_same_principal(pool: PgPool) {
|
|||||||
.await;
|
.await;
|
||||||
via_key.assert_status_ok();
|
via_key.assert_status_ok();
|
||||||
|
|
||||||
assert_eq!(via_session.json::<Value>()["id"], via_key.json::<Value>()["id"]);
|
assert_eq!(
|
||||||
|
via_session.json::<Value>()["id"],
|
||||||
|
via_key.json::<Value>()["id"]
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
via_session.json::<Value>()["username"],
|
via_session.json::<Value>()["username"],
|
||||||
via_key.json::<Value>()["username"]
|
via_key.json::<Value>()["username"]
|
||||||
@@ -352,7 +368,10 @@ async fn read_only_key_cannot_write_scripts(pool: PgPool) {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
mint.assert_status(axum::http::StatusCode::CREATED);
|
mint.assert_status(axum::http::StatusCode::CREATED);
|
||||||
let raw = mint.json::<Value>()["raw_token"].as_str().unwrap().to_string();
|
let raw = mint.json::<Value>()["raw_token"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let denied = s
|
let denied = s
|
||||||
.server
|
.server
|
||||||
@@ -399,7 +418,10 @@ async fn bound_key_cannot_escape_its_app(pool: PgPool) {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
mint.assert_status(axum::http::StatusCode::CREATED);
|
mint.assert_status(axum::http::StatusCode::CREATED);
|
||||||
let raw = mint.json::<Value>()["raw_token"].as_str().unwrap().to_string();
|
let raw = mint.json::<Value>()["raw_token"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
// Writing into the bound app: allowed.
|
// Writing into the bound app: allowed.
|
||||||
let ok = s
|
let ok = s
|
||||||
@@ -473,7 +495,11 @@ async fn member_list_endpoints_filter_at_sql(pool: PgPool) {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|a| a["slug"].as_str().unwrap().to_string())
|
.map(|a| a["slug"].as_str().unwrap().to_string())
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(app_slugs, vec!["default"], "member must see only their apps");
|
assert_eq!(
|
||||||
|
app_slugs,
|
||||||
|
vec!["default"],
|
||||||
|
"member must see only their apps"
|
||||||
|
);
|
||||||
|
|
||||||
let scripts = s
|
let scripts = s
|
||||||
.server
|
.server
|
||||||
@@ -489,8 +515,7 @@ async fn member_list_endpoints_filter_at_sql(pool: PgPool) {
|
|||||||
.map(|s| s["name"].as_str().unwrap().to_string())
|
.map(|s| s["name"].as_str().unwrap().to_string())
|
||||||
.collect();
|
.collect();
|
||||||
assert!(
|
assert!(
|
||||||
names.iter().any(|n| n == "default-script")
|
names.iter().any(|n| n == "default-script") && !names.iter().any(|n| n == "secret-script"),
|
||||||
&& !names.iter().any(|n| n == "secret-script"),
|
|
||||||
"member listing leaked another app's script: {names:?}"
|
"member listing leaked another app's script: {names:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -515,7 +540,10 @@ async fn deactivating_user_revokes_their_api_keys(pool: PgPool) {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
mint.assert_status(axum::http::StatusCode::CREATED);
|
mint.assert_status(axum::http::StatusCode::CREATED);
|
||||||
let raw = mint.json::<Value>()["raw_token"].as_str().unwrap().to_string();
|
let raw = mint.json::<Value>()["raw_token"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
// Key works.
|
// Key works.
|
||||||
let before = s
|
let before = s
|
||||||
@@ -549,7 +577,9 @@ async fn deactivating_user_revokes_their_api_keys(pool: PgPool) {
|
|||||||
rows.iter().all(|r| r.expires_at.is_some()),
|
rows.iter().all(|r| r.expires_at.is_some()),
|
||||||
"every key must have an expiry after deactivation"
|
"every key must have an expiry after deactivation"
|
||||||
);
|
);
|
||||||
assert!(rows.iter().all(|r| r.expires_at.unwrap() <= chrono::Utc::now()));
|
assert!(rows
|
||||||
|
.iter()
|
||||||
|
.all(|r| r.expires_at.unwrap() <= chrono::Utc::now()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -571,7 +601,10 @@ async fn bound_key_with_instance_scope_is_rejected(pool: PgPool) {
|
|||||||
"app_id": s.default_app.to_string(),
|
"app_id": s.default_app.to_string(),
|
||||||
}))
|
}))
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(r.status_code(), axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
assert_eq!(
|
||||||
|
r.status_code(),
|
||||||
|
axum::http::StatusCode::UNPROCESSABLE_ENTITY
|
||||||
|
);
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert!(
|
assert!(
|
||||||
body["error"].as_str().unwrap().contains("bound"),
|
body["error"].as_str().unwrap().contains("bound"),
|
||||||
|
|||||||
@@ -196,7 +196,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn instance_role_round_trip() {
|
fn instance_role_round_trip() {
|
||||||
for role in [InstanceRole::Owner, InstanceRole::Admin, InstanceRole::Member] {
|
for role in [
|
||||||
|
InstanceRole::Owner,
|
||||||
|
InstanceRole::Admin,
|
||||||
|
InstanceRole::Member,
|
||||||
|
] {
|
||||||
assert_eq!(InstanceRole::from_db_str(role.as_str()), Some(role));
|
assert_eq!(InstanceRole::from_db_str(role.as_str()), Some(role));
|
||||||
}
|
}
|
||||||
assert_eq!(InstanceRole::from_db_str("bogus"), None);
|
assert_eq!(InstanceRole::from_db_str("bogus"), None);
|
||||||
|
|||||||
Reference in New Issue
Block a user