1
0
Fork 0
forked from 2sys/phonograph
phonograph/phono-server/src/invites.rs
2025-12-18 20:05:46 +00:00

207 lines
7.7 KiB
Rust

use std::fmt::Display;
use derive_builder::Builder;
use phono_backends::client::WorkspaceClient;
use phono_models::{
invite::Invite, invite_rel_permission::RelPermissionKind, service_cred::ServiceCred, user::User,
};
/// Permissions are not always granted to users, but may also be applied to
/// future users (via invites) or service credentials. This enum spans all
/// possible targets to provide a unified representation for
/// [`PermissionsEditor`].
#[derive(Clone, Debug)]
pub(crate) enum Grantee {
User(User),
Invite(Invite),
ServiceCred(ServiceCred),
}
impl Display for Grantee {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::User(user) => write!(f, "usr_{0}", user.id.simple()),
Self::Invite(invite) => write!(f, "invite_{0}", invite.id.simple()),
Self::ServiceCred(cred) => write!(f, "svc_{0}", cred.id.simple()),
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("mismatched PermissionsTarget variant")]
pub(crate) struct InvalidVariantError;
impl TryFrom<Grantee> for User {
type Error = InvalidVariantError;
fn try_from(value: Grantee) -> Result<Self, Self::Error> {
if let Grantee::User(user) = value {
Ok(user)
} else {
Err(InvalidVariantError)
}
}
}
impl TryFrom<Grantee> for Invite {
type Error = InvalidVariantError;
fn try_from(value: Grantee) -> Result<Self, Self::Error> {
if let Grantee::Invite(invite) = value {
Ok(invite)
} else {
Err(InvalidVariantError)
}
}
}
impl TryFrom<Grantee> for ServiceCred {
type Error = InvalidVariantError;
fn try_from(value: Grantee) -> Result<Self, Self::Error> {
if let Grantee::ServiceCred(cred) = value {
Ok(cred)
} else {
Err(InvalidVariantError)
}
}
}
#[derive(Builder, Debug)]
#[builder(
build_fn(error = "QueryError", vis = ""),
pattern = "owned",
vis = "pub(crate)"
)]
#[builder_struct_attr(doc = r"
A generalized mechanism for assigning relation permissions to a [`Grantee`].
It will make a best effort to resolve the delta between
`target.current_perms` and `desired_perms`.
Note that when grantee is not an invite, the permissions held by the
provided [`WorkspaceClient`] determine which actual Postgres roles may be
granted or revoked. For this reason, the requested permissions update may
only be partially applied, even if the [`PermissionsUpdateBuilder::execute`]
call succeeds.
The update operation does not double-check the authenticity of
`target.current_perms`, so it is technically valid to include only the
relevant subset of current relation permissions, so long as the difference
between `target.current_perms` and `desired_perms` remains as intended. For
example, if permissions are being granted only, the caller may leave
`target.current_perms` empty. This caller behavior is not recommended, but
it is permitted in the spirit of laziness.
")]
struct PermissionsUpdate<'a> {
target: Grantee,
desired_perms: HashSet<RelPermission>,
grantor_id: Uuid,
using_app_db: &'a mut AppDbClient,
using_workspace_client: &'a mut WorkspaceClient,
}
impl<'a> PermissionsUpdateBuilder<'a> {
pub(crate) async fn execute(self) -> Result<(), AppError> {
let PermissionsUpdate {
target,
desired_perms: roles,
grantor_id,
using_app_db: app_db,
using_workspace_client: workspace_client,
} = self.build()?;
let perms_to_revoke: HashSet<_> = target.current_perms.difference(&roles).collect();
debug!(
"revoking {roles} from {target}",
roles = perms_to_revoke
.iter()
.map(|role| role.to_string())
.collect::<Vec<_>>()
.join(", "),
);
let perms_to_grant: HashSet<_> = roles.difference(&target.current_perms).collect();
debug!(
"granting {roles} to {target}",
roles = perms_to_grant
.iter()
.map(|role| role.to_string())
.collect::<Vec<_>>()
.join(", "),
);
match target.kind {
GranteeKind::Invite(invite) => {
let ids_to_delete: Vec<Uuid> = InviteRelPermission::belonging_to_invite(invite.id)
.fetch_all(app_db)
.await?
.into_iter()
.filter(|invite_perm| {
perms_to_revoke.iter().any(|perm| {
perm.rel_oid == invite_perm.rel_oid
&& perm.kind == invite_perm.permission
})
})
.map(|rel_invite| rel_invite.id)
.collect();
debug!("deleting {0} invite rel permissions", ids_to_delete.len());
InviteRelPermission::delete_by_ids(ids_to_delete)
.execute(app_db)
.await?;
for perm in perms_to_grant {
debug!("granting {perm}");
InviteRelPermission::upsert()
.invite_id(invite.id)
.rel_oid(perm.rel_oid)
.permission(perm.kind)
.granted_by(grantor_id)
.execute(app_db)
.await?;
}
}
GranteeKind::ServiceCred(ServiceCred { id, .. })
| GranteeKind::User(User { id, .. }) => {
let target_rolname = match target.kind {
GranteeKind::ServiceCred(cred) => cred.rolname.clone(),
GranteeKind::User(_) => format!("{ROLE_PREFIX_USER}{id}", id = id.simple()),
GranteeKind::Invite(_) => unreachable!("outer match arm excludes variant"),
};
for perm in perms_to_revoke {
let rel = PgClass::with_oid(perm.rel_oid)
.fetch_one(workspace_client)
.await?;
let target_rolname_esc = escape_identifier(&target_rolname);
let perm_rolname = match perm.kind {
RelPermissionKind::Owner => get_owner_role(&rel),
RelPermissionKind::Reader => get_reader_role(&rel),
RelPermissionKind::Writer => get_writer_role(&rel),
}?;
let perm_rolname_esc = escape_identifier(&perm_rolname);
query(&format!(
"revoke {perm_rolname_esc} from {target_rolname_esc}"
))
.execute(workspace_client.get_conn())
.await?;
}
for perm in perms_to_grant {
let rel = PgClass::with_oid(perm.rel_oid)
.fetch_one(workspace_client)
.await?;
let target_rolname_esc = escape_identifier(&target_rolname);
let perm_rolname = match perm.kind {
RelPermissionKind::Owner => get_owner_role(&rel),
RelPermissionKind::Reader => get_reader_role(&rel),
RelPermissionKind::Writer => get_writer_role(&rel),
}?;
let perm_rolname_esc = escape_identifier(&perm_rolname);
query(&format!(
"grant {perm_rolname_esc} to {target_rolname_esc} with admin option"
))
.execute(workspace_client.get_conn())
.await?;
}
}
}
Ok(())
}
}