feat(music): move playlist to authz engine

This commit is contained in:
Edouard Vanbelle
2026-07-08 00:23:25 +02:00
parent 57bea52125
commit c1e46910b0
12 changed files with 856 additions and 269 deletions
+3
View File
@@ -62,6 +62,7 @@ pub enum ResourceTypeDto {
Drive,
Calendar,
AddressBook,
Playlist,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -79,6 +80,7 @@ impl From<ResourceDto> for Resource {
ResourceTypeDto::Drive => Resource::Drive(dto.id),
ResourceTypeDto::Calendar => Resource::Calendar(dto.id),
ResourceTypeDto::AddressBook => Resource::AddressBook(dto.id),
ResourceTypeDto::Playlist => Resource::Playlist(dto.id),
}
}
}
@@ -91,6 +93,7 @@ impl From<Resource> for ResourceDto {
Resource::Drive(id) => (ResourceTypeDto::Drive, id),
Resource::Calendar(id) => (ResourceTypeDto::Calendar, id),
Resource::AddressBook(id) => (ResourceTypeDto::AddressBook, id),
Resource::Playlist(id) => (ResourceTypeDto::Playlist, id),
};
ResourceDto { kind, id }
}
@@ -64,6 +64,7 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
Resource::Drive(id) => ("Drive", id),
Resource::Calendar(id) => ("Calendar", id),
Resource::AddressBook(id) => ("AddressBook", id),
Resource::Playlist(id) => ("Playlist", id),
};
// Audit-worthy: denials are the interesting signal. Routed
// through the `audit` tracing target so log aggregators can
@@ -307,17 +307,22 @@ impl MagicLinkInviteService {
let (kind, resource_id) = match resource {
Resource::Folder(id) => (MagicLinkResourceKind::Folder, id),
Resource::File(id) => (MagicLinkResourceKind::File, id),
// Drive / Calendar / AddressBook sharing is out-of-band for
// the magic-link flow. Drive shares land through
// `/api/drives/{id}/members`; Calendar / AddressBook shares
// through the Round-3 `/api/(calendars|address-books)/{id}/shares`
// endpoints. The DTOs accept every `Resource` variant on
// the wire (see `ResourceTypeDto`) but only file/folder
// grants trigger an invitation email. Treating the other
// arms as audit-logged suppressed no-ops keeps the grant
// in place while matching the ineligible-recipient branch
// Drive / Calendar / AddressBook / Playlist sharing is
// out-of-band for the magic-link flow. Drive shares land
// through `/api/drives/{id}/members`; Calendar /
// AddressBook shares through the Round-3
// `/api/(calendars|address-books)/{id}/shares` endpoints;
// Playlist shares through `/api/playlists/{id}/share`.
// The DTOs accept every `Resource` variant on the wire
// (see `ResourceTypeDto`) but only file/folder grants
// trigger an invitation email. Treating the other arms
// as audit-logged suppressed no-ops keeps the grant in
// place while matching the ineligible-recipient branch
// above.
Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => {
Resource::Drive(_)
| Resource::Calendar(_)
| Resource::AddressBook(_)
| Resource::Playlist(_) => {
tracing::info!(
target: "audit",
event = "magic_link.invitation_suppressed",
@@ -352,12 +357,13 @@ impl MagicLinkInviteService {
Resource::Folder(_) => "server.magic_link.email.kind_folder",
Resource::File(_) => "server.magic_link.email.kind_file",
// Unreachable — the early-return above exits before we get
// here for Drive / Calendar / AddressBook resources. The
// arms exist only to satisfy exhaustiveness; if you find
// any firing, the early-return was bypassed.
Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => {
"server.magic_link.email.kind_folder"
}
// here for Drive / Calendar / AddressBook / Playlist
// resources. The arms exist only to satisfy exhaustiveness;
// if you find any firing, the early-return was bypassed.
Resource::Drive(_)
| Resource::Calendar(_)
| Resource::AddressBook(_)
| Resource::Playlist(_) => "server.magic_link.email.kind_folder",
};
// PR C: render in the recipient's preferred locale (set by UI
// switcher, OIDC JIT claim, or inviter inheritance at row
+220 -226
View File
@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::sync::Arc;
use uuid::Uuid;
@@ -8,28 +9,77 @@ use crate::application::dtos::playlist_dto::{
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::music_ports::{MusicStoragePort, MusicUseCase};
use crate::common::errors::{DomainError, ErrorKind};
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
use crate::infrastructure::adapters::music_storage_adapter::MusicStorageAdapter;
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
/// Music service — the REST entry point for every playlist or audio
/// metadata operation. Every method routes through
/// `AuthorizationEngine`; the pre-Round-3 `user_has_access` /
/// `user_can_write` bespoke helpers on `MusicStorageAdapter` are no
/// longer consulted for access decisions.
///
/// Ownership + sharing live entirely in `storage.role_grants`
/// (`resource_type='playlist'`). `audio.playlists.owner_id` stays for
/// provenance and legacy queries; `audio.playlist_shares` is
/// backfilled and slated for removal in a follow-up migration.
pub struct MusicService {
storage: Arc<MusicStorageAdapter>,
/// ReBAC engine — Round 1 fix from `docs/plan/authz_audit/`.
/// Currently used ONLY by `get_audio_metadata` to close the
/// cross-tenant IDOR (`_user_id: Uuid` was deliberately unused).
/// The full engine rewrite (Round 3 — `Resource::Playlist` +
/// authz.require on every playlist verb) is a separate PR;
/// don't extend the bespoke `user_has_access` / `user_can_write`
/// pattern to new methods, use `require` here instead.
authorization: Arc<PgAclEngine>,
/// ReBAC engine — every user-facing method calls `authz.require`
/// with the appropriate `Permission`. `create_playlist` also uses
/// it to seed an Owner grant for the caller, so the common
/// "owning my own playlist" case takes a single indexed
/// role_grants lookup on subsequent reads.
authz: Arc<PgAclEngine>,
}
impl MusicService {
pub fn new(storage: Arc<MusicStorageAdapter>, authorization: Arc<PgAclEngine>) -> Self {
Self {
storage,
authorization,
}
pub fn new(storage: Arc<MusicStorageAdapter>, authz: Arc<PgAclEngine>) -> Self {
Self { storage, authz }
}
/// Parse `playlist_id` and enforce `permission` on
/// `Resource::Playlist(uuid)`. On denial `authz.require` returns
/// `NotFound` (anti-enum — same shape as "no such playlist") and
/// emits the `authz.denied` audit line. Returns the parsed UUID
/// on success so the caller doesn't have to parse it a second
/// time.
async fn require_playlist_perm(
&self,
playlist_id: &str,
caller_id: Uuid,
permission: Permission,
) -> Result<Uuid, DomainError> {
let uuid = Uuid::parse_str(playlist_id)
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid ID"))?;
self.authz
.require(
Subject::User(caller_id),
permission,
Resource::Playlist(uuid),
)
.await?;
Ok(uuid)
}
/// Check `permission` on a playlist without throwing. Used by the
/// read paths that also allow a public-playlist bypass — they
/// need a bool, not a `Result<(), NotFound>`.
async fn has_playlist_perm(
&self,
playlist_id: &str,
caller_id: Uuid,
permission: Permission,
) -> Result<bool, DomainError> {
let uuid = Uuid::parse_str(playlist_id)
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid ID"))?;
self.authz
.check(
Subject::User(caller_id),
permission,
Resource::Playlist(uuid),
)
.await
}
}
@@ -39,7 +89,26 @@ impl MusicUseCase for MusicService {
dto: CreatePlaylistDto,
user_id: Uuid,
) -> Result<PlaylistDto, DomainError> {
self.storage.create_playlist(dto, user_id).await
// No pre-write gate: creating a playlist is a personal act.
// Storage stamps `owner_id = user_id`; we then seed an Owner
// role_grant so subsequent reads hit the same
// `storage.role_grants` fast path used everywhere else.
let created = self.storage.create_playlist(dto, user_id).await?;
let playlist_uuid = Uuid::parse_str(&created.id).map_err(|_| {
DomainError::internal_error("Playlist", "storage returned invalid playlist id")
})?;
// `set_role` is idempotent on the `(subject, resource)` unique
// key. `granted_by = user_id` is the self-seeded creation event.
self.authz
.set_role(
user_id,
Subject::User(user_id),
Role::Owner,
Resource::Playlist(playlist_uuid),
None,
)
.await?;
Ok(created)
}
async fn update_playlist(
@@ -48,45 +117,25 @@ impl MusicUseCase for MusicService {
dto: UpdatePlaylistDto,
user_id: Uuid,
) -> Result<PlaylistDto, DomainError> {
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
if !has_access {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Playlist",
"You don't have permission to update this playlist",
));
}
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
if !can_write {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Playlist",
"You need write access to update this playlist",
));
}
self.require_playlist_perm(playlist_id, user_id, Permission::Update)
.await?;
self.storage.update_playlist(playlist_id, dto).await
}
async fn delete_playlist(&self, playlist_id: &str, user_id: Uuid) -> Result<(), DomainError> {
let playlist = self.storage.get_playlist(playlist_id).await?;
let playlist = match playlist {
Some(p) => p,
None => {
return Err(DomainError::new(
ErrorKind::NotFound,
"Playlist",
"Playlist not found",
));
}
};
if playlist.owner_id != user_id.to_string() {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Playlist",
"Only the owner can delete this playlist",
));
}
self.storage.delete_playlist(playlist_id).await
let uuid = self
.require_playlist_perm(playlist_id, user_id, Permission::Delete)
.await?;
self.storage.delete_playlist(playlist_id).await?;
// Wipe every grant on this playlist so a re-used UUID
// (impossible today but cheap to defend against) doesn't
// inherit stale ACLs. The storage DELETE won't cascade to
// `storage.role_grants` — it's cross-schema.
let _ = self
.authz
.revoke_all_for_resource(Resource::Playlist(uuid))
.await;
Ok(())
}
async fn get_playlist(
@@ -94,23 +143,22 @@ impl MusicUseCase for MusicService {
playlist_id: &str,
user_id: Uuid,
) -> Result<PlaylistDto, DomainError> {
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
if !has_access {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Playlist",
"You don't have permission to view this playlist",
));
}
let playlist = self.storage.get_playlist(playlist_id).await?;
match playlist {
Some(p) => Ok(p),
None => Err(DomainError::new(
ErrorKind::NotFound,
"Playlist",
"Playlist not found",
)),
let playlist = match playlist {
Some(p) => p,
None => return Err(DomainError::not_found("Playlist", playlist_id)),
};
// Public-playlist bypass: anonymous-ish read. `check` returns
// bool (no throw); combine with the public flag before
// deciding.
let allowed = playlist.is_public
|| self
.has_playlist_perm(playlist_id, user_id, Permission::Read)
.await?;
if !allowed {
return Err(DomainError::not_found("Playlist", playlist_id));
}
Ok(playlist)
}
async fn list_playlists(
@@ -123,17 +171,38 @@ impl MusicUseCase for MusicService {
let limit = query.limit.unwrap_or(100);
let offset = query.offset.unwrap_or(0);
let mut playlists = Vec::new();
// Post-Round-3 semantics: playlists the caller has any grant
// on come from `list_incoming_grants` — one union of owned +
// shared. The pre-Round-3 code fetched them via two separate
// queries (`list_playlists_by_owner` + `list_shared_with_user`)
// that each read a different table.
let grants = self
.authz
.list_incoming_grants(Subject::User(user_id))
.await?;
let owned = self.storage.list_playlists_by_owner(user_id).await?;
playlists.extend(owned);
// Deduplicate — a user can hold multiple grants on the same
// playlist (direct + group-inherited). We only need one DTO
// per resource.
let mut playlist_ids: HashSet<Uuid> = grants
.into_iter()
.filter_map(|g| match g.resource {
Resource::Playlist(id) => Some(id),
_ => None,
})
.collect();
if include_shared {
let shared = self.storage.list_shared_with_user(user_id).await?;
for s in shared {
if !playlists.iter().any(|p: &PlaylistDto| p.id == s.id) {
playlists.push(s);
}
// `include_shared=false` narrows the listing to owned playlists
// only. Owner is a grant like any other in `role_grants`, so we
// filter the aggregated set against the owner_id stamped on
// each row after hydration — cheaper than a second SQL round-trip.
let mut playlists: Vec<PlaylistDto> = Vec::with_capacity(playlist_ids.len());
let user_str = user_id.to_string();
for id in playlist_ids.drain() {
if let Ok(Some(p)) = self.storage.get_playlist(&id.to_string()).await
&& (include_shared || p.owner_id == user_str)
{
playlists.push(p);
}
}
@@ -155,26 +224,9 @@ impl MusicUseCase for MusicService {
dto: AddTracksDto,
user_id: Uuid,
) -> Result<Vec<PlaylistItemDto>, DomainError> {
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
})?;
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
if !has_access {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Playlist",
"You don't have permission to modify this playlist",
));
}
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
if !can_write {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Playlist",
"You need write access to add tracks",
));
}
let playlist_uuid = self
.require_playlist_perm(playlist_id, user_id, Permission::Update)
.await?;
let file_ids: Result<Vec<Uuid>, _> =
dto.file_ids.iter().map(|id| Uuid::parse_str(id)).collect();
@@ -191,30 +243,12 @@ impl MusicUseCase for MusicService {
file_id: &str,
user_id: Uuid,
) -> Result<(), DomainError> {
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
})?;
let playlist_uuid = self
.require_playlist_perm(playlist_id, user_id, Permission::Update)
.await?;
let file_uuid = Uuid::parse_str(file_id).map_err(|_| {
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid file ID")
})?;
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
if !has_access {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Playlist",
"You don't have permission to modify this playlist",
));
}
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
if !can_write {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Playlist",
"You need write access to remove tracks",
));
}
self.storage.remove_track(&playlist_uuid, &file_uuid).await
}
@@ -224,26 +258,9 @@ impl MusicUseCase for MusicService {
dto: ReorderTracksDto,
user_id: Uuid,
) -> Result<(), DomainError> {
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
})?;
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
if !has_access {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Playlist",
"You don't have permission to modify this playlist",
));
}
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
if !can_write {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Playlist",
"You need write access to reorder tracks",
));
}
let playlist_uuid = self
.require_playlist_perm(playlist_id, user_id, Permission::Update)
.await?;
let item_ids: Result<Vec<Uuid>, _> =
dto.item_ids.iter().map(|id| Uuid::parse_str(id)).collect();
@@ -262,16 +279,21 @@ impl MusicUseCase for MusicService {
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
})?;
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
if !has_access {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Playlist",
"You don't have permission to view this playlist",
));
// Public-playlist bypass mirrors `get_playlist`: readers of a
// public playlist can see its tracks. Fetch the playlist row
// to inspect `is_public` before deciding.
let playlist = self
.storage
.get_playlist(playlist_id)
.await?
.ok_or_else(|| DomainError::not_found("Playlist", playlist_id))?;
let allowed = playlist.is_public
|| self
.has_playlist_perm(playlist_id, user_id, Permission::Read)
.await?;
if !allowed {
return Err(DomainError::not_found("Playlist", playlist_id));
}
self.storage.list_playlist_tracks(&playlist_uuid).await
}
@@ -281,36 +303,33 @@ impl MusicUseCase for MusicService {
dto: SharePlaylistDto,
caller_id: Uuid,
) -> Result<(), DomainError> {
let playlist = self.storage.get_playlist(playlist_id).await?;
let playlist = match playlist {
Some(p) => p,
None => {
return Err(DomainError::new(
ErrorKind::NotFound,
"Playlist",
"Playlist not found",
));
}
};
if playlist.owner_id != caller_id.to_string() {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Playlist",
"Only the owner can share this playlist",
));
}
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
})?;
let playlist_uuid = self
.require_playlist_perm(playlist_id, caller_id, Permission::Share)
.await?;
let target_user_id = Uuid::parse_str(&dto.user_id).map_err(|_| {
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid user ID")
})?;
let can_write = dto.can_write.unwrap_or(false);
self.storage
.share_playlist(&playlist_uuid, target_user_id, can_write)
.await
// Legacy `can_write` boolean maps into the role bundle system:
// - false → Viewer (Read only)
// - true → Editor (Read + Update)
// The endpoint stays boolean-shaped for API back-compat; new
// integrations should switch to the unified `/api/grants` API
// which exposes the full role set.
let role = if dto.can_write.unwrap_or(false) {
Role::Editor
} else {
Role::Viewer
};
self.authz
.set_role(
caller_id,
Subject::User(target_user_id),
role,
Resource::Playlist(playlist_uuid),
None,
)
.await?;
Ok(())
}
async fn remove_share(
@@ -319,33 +338,18 @@ impl MusicUseCase for MusicService {
target_user_id: &str,
caller_id: Uuid,
) -> Result<(), DomainError> {
let playlist = self.storage.get_playlist(playlist_id).await?;
let playlist = match playlist {
Some(p) => p,
None => {
return Err(DomainError::new(
ErrorKind::NotFound,
"Playlist",
"Playlist not found",
));
}
};
if playlist.owner_id != caller_id.to_string() {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Playlist",
"Only the owner can manage sharing",
));
}
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
})?;
let playlist_uuid = self
.require_playlist_perm(playlist_id, caller_id, Permission::Share)
.await?;
let target_uuid = Uuid::parse_str(target_user_id).map_err(|_| {
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid user ID")
})?;
self.storage.remove_share(&playlist_uuid, target_uuid).await
self.authz
.clear_role(
Subject::User(target_uuid),
Resource::Playlist(playlist_uuid),
)
.await
}
async fn get_playlist_shares(
@@ -353,35 +357,26 @@ impl MusicUseCase for MusicService {
playlist_id: &str,
user_id: Uuid,
) -> Result<Vec<PlaylistShareInfoDto>, DomainError> {
let playlist = self.storage.get_playlist(playlist_id).await?;
let playlist = match playlist {
Some(p) => p,
None => {
return Err(DomainError::new(
ErrorKind::NotFound,
"Playlist",
"Playlist not found",
));
}
};
if playlist.owner_id != user_id.to_string() {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Playlist",
"Only the owner can view sharing info",
));
}
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
})?;
let shares = self.storage.get_shares(&playlist_uuid).await?;
Ok(shares
let playlist_uuid = self
.require_playlist_perm(playlist_id, user_id, Permission::Share)
.await?;
// `list_grants_on_resource` returns every role_grant row for
// the playlist. Drop the Owner self-grant seeded at creation
// (the caller already knows they own it) and collapse the
// role bundle back to a boolean `can_write` for the legacy
// DTO shape.
let grants = self
.authz
.list_grants_on_resource(Resource::Playlist(playlist_uuid))
.await?;
Ok(grants
.into_iter()
.map(|(uid, can_write)| PlaylistShareInfoDto {
user_id: uid.to_string(),
can_write,
.filter_map(|g| match g.subject {
Subject::User(uid) if g.role != Role::Owner => Some(PlaylistShareInfoDto {
user_id: uid.to_string(),
can_write: g.role.expand().contains(&Permission::Update),
}),
_ => None,
})
.collect())
}
@@ -398,9 +393,8 @@ impl MusicUseCase for MusicService {
// metadata for any known file id (cross-tenant IDOR — the
// `_user_id` parameter was deliberately unused). `require`
// returns 404 on denial to match the anti-enum shape used
// everywhere else. Post-Drive AuthZ audit fix (Round 1
// BLOCKER — `docs/plan/authz_audit/rest_storage.md`).
self.authorization
// everywhere else.
self.authz
.require(
Subject::User(caller_id),
Permission::Read,
@@ -472,13 +472,14 @@ impl RecipientNotificationService {
let kind_key = match resource {
Resource::Folder(_) => "server.magic_link.email.kind_folder",
Resource::File(_) => "server.magic_link.email.kind_file",
// Drive / Calendar / AddressBook shares don't produce
// email notifications through this path. Fall back to the
// folder label so any code that does reach here still
// produces a readable (if generic) mail body.
Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => {
"server.magic_link.email.kind_folder"
}
// Drive / Calendar / AddressBook / Playlist shares don't
// produce email notifications through this path. Fall
// back to the folder label so any code that does reach
// here still produces a readable (if generic) mail body.
Resource::Drive(_)
| Resource::Calendar(_)
| Resource::AddressBook(_)
| Resource::Playlist(_) => "server.magic_link.email.kind_folder",
};
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
// Short form for the subject, long form (with email) for the
+11 -9
View File
@@ -90,9 +90,12 @@ pub enum Resource {
/// replaces `carddav.address_book_shares` and the
/// `check_address_book_access` bespoke helper.
AddressBook(Uuid),
// Reserved for future use — same shape but tracked separately
// (music-service rewrite is its own PR):
// Playlist(Uuid),
/// A music playlist. Same shape as `Calendar`/`AddressBook` —
/// `storage.role_grants` with `resource_type='playlist'` replaces
/// the pre-Round-3 dedicated `music.playlist_shares` table and the
/// bespoke `user_has_access` / `user_can_write` helpers on
/// `MusicStorageAdapter`.
Playlist(Uuid),
}
impl Resource {
@@ -103,7 +106,7 @@ impl Resource {
Resource::Drive(_) => "drive",
Resource::Calendar(_) => "calendar",
Resource::AddressBook(_) => "address_book",
//Resource::Playlist(_) => "playlist",
Resource::Playlist(_) => "playlist",
}
}
@@ -113,8 +116,8 @@ impl Resource {
| Resource::File(id)
| Resource::Drive(id)
| Resource::Calendar(id)
| Resource::AddressBook(id) => *id,
//| Resource::Playlist(id)
| Resource::AddressBook(id)
| Resource::Playlist(id) => *id,
}
}
@@ -125,7 +128,7 @@ impl Resource {
"drive" => Some(Resource::Drive(id)),
"calendar" => Some(Resource::Calendar(id)),
"address_book" => Some(Resource::AddressBook(id)),
//"playlist" => Some(Resource::Playlist(id)),
"playlist" => Some(Resource::Playlist(id)),
_ => None,
}
}
@@ -552,12 +555,11 @@ mod tests {
Resource::File(id),
Resource::Calendar(id),
Resource::AddressBook(id),
Resource::Playlist(id),
] {
let back = Resource::from_parts(r.type_str(), r.id()).unwrap();
assert_eq!(r, back);
}
// `playlist` is still pending the Music AuthZ migration.
assert!(Resource::from_parts("playlist", id).is_none());
}
#[test]
+24 -6
View File
@@ -480,17 +480,22 @@ impl PgAclEngine {
/// drive — this returns `NotFound` for `Resource::Drive` and the caller
/// must not invoke it on Drive resources.
///
/// `Resource::Calendar` and `Resource::AddressBook` are top-level per
/// user with no drive ancestor; they also return `NotFound` and the
/// engine short-circuits to a direct `role_grants` lookup (no drive
/// `Resource::Calendar`, `Resource::AddressBook` and
/// `Resource::Playlist` are top-level per user with no drive
/// ancestor; they also return `NotFound` and the engine
/// short-circuits to a direct `role_grants` lookup (no drive
/// precheck applies).
async fn drive_of(&self, resource: Resource) -> Result<Uuid, DomainError> {
match resource {
Resource::Folder(id) => self.folder_repo.get_folder_drive_id(&id.to_string()).await,
Resource::File(id) => self.file_repo.get_file_drive_id(&id.to_string()).await,
Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => Err(
DomainError::not_found(resource.type_str(), resource.id().to_string()),
),
Resource::Drive(_)
| Resource::Calendar(_)
| Resource::AddressBook(_)
| Resource::Playlist(_) => Err(DomainError::not_found(
resource.type_str(),
resource.id().to_string(),
)),
}
}
@@ -902,6 +907,19 @@ impl PgAclEngine {
)
.await
}
Resource::Playlist(id) => {
let (subject_types, subject_ids) =
self.subject_match_set(subject, counters).await?;
self.direct_grant_exists(
&subject_types,
&subject_ids,
permission,
"playlist",
id,
counters,
)
.await
}
}
}
}
+5 -5
View File
@@ -113,12 +113,12 @@ pub async fn create_grant(
.get_by_id(id)
.await
.map(|d| d.drive.typed_policies()),
// Calendars and address books live outside the drive
// hierarchy (top-level per user), so no drive-level policy
// gates apply. If per-calendar / per-address-book policies
// ever ship, they'll live on the resource itself, not on a
// Calendars, address books and playlists live outside the
// drive hierarchy (top-level per user), so no drive-level
// policy gates apply. If per-resource policies ever ship for
// these kinds, they'll live on the resource itself, not on a
// drive; the default-empty bag is the right no-op here.
Resource::Calendar(_) | Resource::AddressBook(_) => {
Resource::Calendar(_) | Resource::AddressBook(_) | Resource::Playlist(_) => {
Ok(crate::domain::entities::drive::DrivePolicies::default())
}
};