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 for User { type Error = InvalidVariantError; fn try_from(value: Grantee) -> Result { if let Grantee::User(user) = value { Ok(user) } else { Err(InvalidVariantError) } } } impl TryFrom for Invite { type Error = InvalidVariantError; fn try_from(value: Grantee) -> Result { if let Grantee::Invite(invite) = value { Ok(invite) } else { Err(InvalidVariantError) } } } impl TryFrom for ServiceCred { type Error = InvalidVariantError; fn try_from(value: Grantee) -> Result { 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, 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::>() .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::>() .join(", "), ); match target.kind { GranteeKind::Invite(invite) => { let ids_to_delete: Vec = 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(()) } }