feat(v1.1.8): per-app roles + roles in user record (migration 0031)

migration 0031: app_user_roles table — composite PK (app_id, user_id,
role) so add is idempotent (ON CONFLICT DO NOTHING). v1.1.8 stores
strings only; permission matrices / hierarchies / role registry are
explicitly v1.2 work per the brief.

UsersServiceImpl wires roles: Arc<dyn AppUserRoleRepo>:

  * fetch_roles() now actually queries the repo (replacing the empty
    Vec stub from commit 4). Every AppUser returned from get /
    find_by_email / list / update / verify / login now carries its
    role list.
  * users::add_role gated on AppUsersAdmin; first checks the user
    exists in this app so a FK violation can't leak "no such user".
  * users::remove_role gated on AppUsersAdmin; idempotent.
  * users::has_role gated on AppUsersRead.
  * accept_invite now applies pre-staged roles atomically with the
    user creation; malformed role strings are skipped with a warn
    rather than aborting the whole accept (the invitation was an
    admin's promise — we honor as much of it as we can).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-06 12:13:35 +02:00
parent b07382e64b
commit 3af99873c3
5 changed files with 240 additions and 30 deletions

View File

@@ -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<dyn AppUserVerificationRepo>,
password_resets: Arc<dyn AppUserPasswordResetRepo>,
invitations: Arc<dyn AppUserInvitationRepo>,
roles: Arc<dyn AppUserRoleRepo>,
email: Arc<dyn EmailService>,
authz: Arc<dyn AuthzRepo>,
events: Arc<dyn ServiceEventEmitter>,
@@ -144,6 +146,7 @@ impl UsersServiceImpl {
verifications: Arc<dyn AppUserVerificationRepo>,
password_resets: Arc<dyn AppUserPasswordResetRepo>,
invitations: Arc<dyn AppUserInvitationRepo>,
roles: Arc<dyn AppUserRoleRepo>,
email: Arc<dyn EmailService>,
authz: Arc<dyn AuthzRepo>,
events: Arc<dyn ServiceEventEmitter>,
@@ -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<Vec<String>, 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<Vec<String>, 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<AppUserInvitationRepoError> for UsersError {
}
}
impl From<AppUserRoleRepoError> for UsersError {
fn from(e: AppUserRoleRepoError) -> Self {
match e {
AppUserRoleRepoError::Db(err) => UsersError::Backend(err.to_string()),
}
}
}
fn validate_role(role: &str) -> Result<String, UsersError> {
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<String> = 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<bool, UsersError> {
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<bool, UsersError> {
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,