diff --git a/crates/manager-core/migrations/0031_app_user_roles.sql b/crates/manager-core/migrations/0031_app_user_roles.sql new file mode 100644 index 0000000..948f946 --- /dev/null +++ b/crates/manager-core/migrations/0031_app_user_roles.sql @@ -0,0 +1,21 @@ +-- v1.1.8 User Management — per-app string-tagged roles. +-- +-- v1.1.8 ships ROLE STORAGE ONLY. There is no role registry, no +-- hierarchy, no permission matrix — what "admin" / "editor" / +-- "viewer" mean is up to the script app. The `users::*` SDK +-- surfaces a Vec on every AppUser record; the surrounding +-- script reads it and gates behavior accordingly. +-- +-- Per-role permission matrices are a v1.2 design item (see brief); +-- pre-baking them would either cement a wrong model or force a +-- breaking change at v1.2. + +CREATE TABLE app_user_roles ( + app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, + role TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (app_id, user_id, role) +); + +CREATE INDEX idx_app_user_roles_user ON app_user_roles (app_id, user_id); diff --git a/crates/manager-core/src/app_user_role_repo.rs b/crates/manager-core/src/app_user_role_repo.rs new file mode 100644 index 0000000..e1f609f --- /dev/null +++ b/crates/manager-core/src/app_user_role_repo.rs @@ -0,0 +1,132 @@ +//! CRUD over `app_user_roles` (v1.1.8 commit 8). +//! +//! String-tagged roles only. No registry, no hierarchy, no +//! permission matrix — the script app decides what the strings mean. +//! Per-app, per-user; the PK is `(app_id, user_id, role)` so the +//! `add` path is idempotent (`ON CONFLICT DO NOTHING`). + +use async_trait::async_trait; +use picloud_shared::{AppId, AppUserId}; +use sqlx::PgPool; + +#[derive(Debug, thiserror::Error)] +pub enum AppUserRoleRepoError { + #[error("database error: {0}")] + Db(#[from] sqlx::Error), +} + +#[async_trait] +pub trait AppUserRoleRepo: Send + Sync { + /// Idempotent add — duplicate role for the same user is a no-op. + async fn add( + &self, + app_id: AppId, + user_id: AppUserId, + role: &str, + ) -> Result<(), AppUserRoleRepoError>; + + /// Returns whether the role was actually removed (false if it + /// wasn't there). + async fn remove( + &self, + app_id: AppId, + user_id: AppUserId, + role: &str, + ) -> Result; + + async fn has( + &self, + app_id: AppId, + user_id: AppUserId, + role: &str, + ) -> Result; + + /// All roles for a single user. The AppUser DTO carries this list. + async fn list_for_user( + &self, + app_id: AppId, + user_id: AppUserId, + ) -> Result, AppUserRoleRepoError>; +} + +pub struct PostgresAppUserRoleRepo { + pool: PgPool, +} + +impl PostgresAppUserRoleRepo { + #[must_use] + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl AppUserRoleRepo for PostgresAppUserRoleRepo { + async fn add( + &self, + app_id: AppId, + user_id: AppUserId, + role: &str, + ) -> Result<(), AppUserRoleRepoError> { + sqlx::query( + "INSERT INTO app_user_roles (app_id, user_id, role) VALUES ($1, $2, $3) \ + ON CONFLICT (app_id, user_id, role) DO NOTHING", + ) + .bind(app_id.into_inner()) + .bind(user_id.into_inner()) + .bind(role) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn remove( + &self, + app_id: AppId, + user_id: AppUserId, + role: &str, + ) -> Result { + let res = sqlx::query( + "DELETE FROM app_user_roles WHERE app_id = $1 AND user_id = $2 AND role = $3", + ) + .bind(app_id.into_inner()) + .bind(user_id.into_inner()) + .bind(role) + .execute(&self.pool) + .await?; + Ok(res.rows_affected() > 0) + } + + async fn has( + &self, + app_id: AppId, + user_id: AppUserId, + role: &str, + ) -> Result { + let row: Option<(i64,)> = sqlx::query_as( + "SELECT 1::BIGINT FROM app_user_roles \ + WHERE app_id = $1 AND user_id = $2 AND role = $3", + ) + .bind(app_id.into_inner()) + .bind(user_id.into_inner()) + .bind(role) + .fetch_optional(&self.pool) + .await?; + Ok(row.is_some()) + } + + async fn list_for_user( + &self, + app_id: AppId, + user_id: AppUserId, + ) -> Result, AppUserRoleRepoError> { + let rows: Vec<(String,)> = sqlx::query_as( + "SELECT role FROM app_user_roles WHERE app_id = $1 AND user_id = $2 ORDER BY role", + ) + .bind(app_id.into_inner()) + .bind(user_id.into_inner()) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(|(s,)| s).collect()) + } +} diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 6eb97c0..7341efa 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod app_secrets_repo; pub mod app_user_invitation_repo; pub mod app_user_password_reset_repo; pub mod app_user_repo; +pub mod app_user_role_repo; pub mod app_user_session_repo; pub mod app_user_verification_repo; pub mod users_service; @@ -97,6 +98,7 @@ pub use app_user_invitation_repo::{ pub use app_user_password_reset_repo::{ AppUserPasswordResetRepo, AppUserPasswordResetRepoError, PostgresAppUserPasswordResetRepo, }; +pub use app_user_role_repo::{AppUserRoleRepo, AppUserRoleRepoError, PostgresAppUserRoleRepo}; pub use app_user_verification_repo::{ AppUserVerificationRepo, AppUserVerificationRepoError, PostgresAppUserVerificationRepo, }; diff --git a/crates/manager-core/src/users_service.rs b/crates/manager-core/src/users_service.rs index fa1de04..731c87b 100644 --- a/crates/manager-core/src/users_service.rs +++ b/crates/manager-core/src/users_service.rs @@ -40,6 +40,7 @@ use crate::app_user_password_reset_repo::{ use crate::app_user_repo::{ AppUserRepository, AppUserRepositoryError, AppUserRow, ListOpts as RepoListOpts, }; +use crate::app_user_role_repo::{AppUserRoleRepo, AppUserRoleRepoError}; use crate::app_user_session_repo::{AppUserSessionRepository, AppUserSessionRepositoryError}; use crate::app_user_verification_repo::{AppUserVerificationRepo, AppUserVerificationRepoError}; use crate::auth::{ @@ -129,6 +130,7 @@ pub struct UsersServiceImpl { verifications: Arc, password_resets: Arc, invitations: Arc, + roles: Arc, email: Arc, authz: Arc, events: Arc, @@ -144,6 +146,7 @@ impl UsersServiceImpl { verifications: Arc, password_resets: Arc, invitations: Arc, + roles: Arc, email: Arc, authz: Arc, events: Arc, @@ -155,6 +158,7 @@ impl UsersServiceImpl { verifications, password_resets, invitations, + roles, email, authz, events, @@ -192,10 +196,8 @@ impl UsersServiceImpl { } } - async fn fetch_roles(&self, _app_id: AppId, _user_id: AppUserId) -> Result, UsersError> { - // Commit 8 (per-app roles, migration 0031) replaces this with - // a SELECT from app_user_roles. - Ok(Vec::new()) + async fn fetch_roles(&self, app_id: AppId, user_id: AppUserId) -> Result, UsersError> { + Ok(self.roles.list_for_user(app_id, user_id).await?) } async fn emit(&self, cx: &SdkCallCx, op: &'static str, user_id: AppUserId) { @@ -299,6 +301,25 @@ impl From for UsersError { } } +impl From for UsersError { + fn from(e: AppUserRoleRepoError) -> Self { + match e { + AppUserRoleRepoError::Db(err) => UsersError::Backend(err.to_string()), + } + } +} + +fn validate_role(role: &str) -> Result { + let trimmed = role.trim(); + if trimmed.is_empty() { + return Err(UsersError::Invalid("role must not be empty".into())); + } + if trimmed.chars().count() > 128 { + return Err(UsersError::Invalid("role exceeds 128 characters".into())); + } + Ok(trimmed.to_string()) +} + fn map_email_error(e: EmailError) -> UsersError { match e { EmailError::NotConfigured => UsersError::EmailNotConfigured, @@ -812,47 +833,79 @@ impl UsersService for UsersServiceImpl { } Err(e) => return Err(e.into()), }; - // Commit 8 wires the role-application path; in commit 7 the - // staged roles are stored on the invitation row but not - // applied (the role table doesn't exist yet). Logged so an - // operator can audit the gap. - if !consumed.roles.is_empty() { - tracing::info!( - user_id = %row.id, - app_id = %cx.app_id, - roles = ?consumed.roles, - "accept_invite: pre-staged roles will be applied once migration 0031 lands" - ); - } + // Apply pre-staged roles (commit 8). Each role goes through + // the same trim + length validation the script's add_role + // call would; invalid ones are skipped with a warn rather + // than aborting the whole accept (the invitation was a + // promise the admin made; we honor as much of it as we can). let user_id = row.id; - let user = Self::to_app_user(row, Vec::new()); + let mut applied: Vec = Vec::with_capacity(consumed.roles.len()); + for raw_role in &consumed.roles { + match validate_role(raw_role) { + Ok(role) => { + self.roles.add(cx.app_id, user_id, &role).await?; + applied.push(role); + } + Err(e) => { + tracing::warn!( + invalid_role = %raw_role, + user_id = %user_id, + app_id = %cx.app_id, + error = %e, + "accept_invite: skipping malformed pre-staged role" + ); + } + } + } + let user = Self::to_app_user(row, applied); let session = self.mint_session(cx.app_id, user_id, &user).await?; self.emit(cx, "create", user_id).await; Ok(Some(GeneratedAccept { user, session })) } async fn add_role( &self, - _cx: &SdkCallCx, - _user_id: AppUserId, - _role: &str, + cx: &SdkCallCx, + user_id: AppUserId, + role: &str, ) -> Result<(), UsersError> { - Err(UsersError::Backend(NOT_YET_IMPL.into())) + self.require(cx.principal.as_ref(), Capability::AppUsersAdmin(cx.app_id)) + .await?; + // Ensure the user actually exists in this app — otherwise an + // FK-failing insert leaks "no such user" via the error shape. + let exists = self.users.get(cx.app_id, user_id).await?.is_some(); + if !exists { + return Err(UsersError::NotFound); + } + let role = validate_role(role)?; + self.roles.add(cx.app_id, user_id, &role).await?; + self.emit(cx, "role_added", user_id).await; + Ok(()) } async fn remove_role( &self, - _cx: &SdkCallCx, - _user_id: AppUserId, - _role: &str, + cx: &SdkCallCx, + user_id: AppUserId, + role: &str, ) -> Result { - Err(UsersError::Backend(NOT_YET_IMPL.into())) + self.require(cx.principal.as_ref(), Capability::AppUsersAdmin(cx.app_id)) + .await?; + let role = validate_role(role)?; + let removed = self.roles.remove(cx.app_id, user_id, &role).await?; + if removed { + self.emit(cx, "role_removed", user_id).await; + } + Ok(removed) } async fn has_role( &self, - _cx: &SdkCallCx, - _user_id: AppUserId, - _role: &str, + cx: &SdkCallCx, + user_id: AppUserId, + role: &str, ) -> Result { - Err(UsersError::Backend(NOT_YET_IMPL.into())) + self.require(cx.principal.as_ref(), Capability::AppUsersRead(cx.app_id)) + .await?; + let role = validate_role(role)?; + Ok(self.roles.has(cx.app_id, user_id, &role).await?) } async fn admin_reset_password_token( &self, diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 7324380..1b9daea 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -22,7 +22,7 @@ use picloud_manager_core::{ OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo, - PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, + PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo, @@ -258,12 +258,14 @@ pub async fn build_app( Arc::new(PostgresAppUserPasswordResetRepo::new(pool.clone())); let app_user_invitations_repo = Arc::new(PostgresAppUserInvitationRepo::new(pool.clone())); + let app_user_roles_repo = Arc::new(PostgresAppUserRoleRepo::new(pool.clone())); let users: Arc = Arc::new(UsersServiceImpl::new( app_users_repo.clone(), app_user_sessions_repo.clone(), app_user_verifications_repo.clone(), app_user_password_resets_repo.clone(), app_user_invitations_repo.clone(), + app_user_roles_repo.clone(), email.clone(), authz.clone(), events.clone(),