feat(calendar,addressbook): prepare authz engine
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────
|
||||
-- Round 3 — admit 'calendar' and 'address_book' into
|
||||
-- `storage.role_grants.resource_type`.
|
||||
--
|
||||
-- Companion to the domain unblock in
|
||||
-- `src/domain/services/authorization.rs` (Round 3 Phase 1). The
|
||||
-- `Resource::Calendar(Uuid)` and `Resource::AddressBook(Uuid)`
|
||||
-- variants can't be inserted into `role_grants` until the CHECK
|
||||
-- constraint on `resource_type` permits their string discriminators.
|
||||
--
|
||||
-- CalDAV and CardDAV surfaces have historically enforced access via
|
||||
-- dedicated per-domain share tables (`caldav.calendar_shares`,
|
||||
-- `carddav.address_book_shares`) and bespoke `check_calendar_access`
|
||||
-- / `check_address_book_access` helpers. Round 3 folds both into the
|
||||
-- unified ReBAC engine so:
|
||||
--
|
||||
-- * A single ACL source of truth (`storage.role_grants`) covers
|
||||
-- every OxiCloud resource type — files, folders, drives,
|
||||
-- calendars, address books.
|
||||
-- * Group subjects become a free feature on calendar/book shares
|
||||
-- (falls out of `role_grants.subject_type='group'`).
|
||||
-- * The `authz.require` audit line ("👮🏻♂️ perms: ⛔ …") fires on
|
||||
-- denial with no per-domain retrofit.
|
||||
--
|
||||
-- Migration of existing rows from `caldav.calendar_shares` and
|
||||
-- `carddav.address_book_shares` into `role_grants` happens in the
|
||||
-- next migration (Phase 2). The legacy tables stay in place through
|
||||
-- this PR for rollback safety; they get dropped one release later.
|
||||
|
||||
-- `resource_type` is a TEXT column with a CHECK constraint (not a PG
|
||||
-- enum), so extending it is a DROP / ADD pair — no `ALTER TYPE` /
|
||||
-- non-transactional migration issues.
|
||||
|
||||
ALTER TABLE storage.role_grants
|
||||
DROP CONSTRAINT IF EXISTS role_grants_resource_type_check;
|
||||
|
||||
ALTER TABLE storage.role_grants
|
||||
ADD CONSTRAINT role_grants_resource_type_check
|
||||
CHECK (resource_type IN ('folder', 'file', 'drive', 'calendar', 'address_book'));
|
||||
|
||||
-- Post-flight: introspect the live constraint definition and prove
|
||||
-- both new values appear. Cheap read-only check with no INSERT.
|
||||
DO $BODY$
|
||||
DECLARE
|
||||
defn TEXT;
|
||||
BEGIN
|
||||
SELECT pg_get_constraintdef(c.oid) INTO defn
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = 'storage'
|
||||
AND t.relname = 'role_grants'
|
||||
AND c.conname = 'role_grants_resource_type_check';
|
||||
|
||||
IF defn IS NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'role_grants_resource_type_check not found on storage.role_grants';
|
||||
END IF;
|
||||
IF position('calendar' IN defn) = 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'CHECK constraint does not admit ''calendar'': %', defn;
|
||||
END IF;
|
||||
IF position('address_book' IN defn) = 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'CHECK constraint does not admit ''address_book'': %', defn;
|
||||
END IF;
|
||||
END;
|
||||
$BODY$;
|
||||
@@ -55,11 +55,13 @@ impl From<Subject> for SubjectDto {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ResourceTypeDto {
|
||||
Folder,
|
||||
File,
|
||||
Drive,
|
||||
Calendar,
|
||||
AddressBook,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
@@ -75,6 +77,8 @@ impl From<ResourceDto> for Resource {
|
||||
ResourceTypeDto::Folder => Resource::Folder(dto.id),
|
||||
ResourceTypeDto::File => Resource::File(dto.id),
|
||||
ResourceTypeDto::Drive => Resource::Drive(dto.id),
|
||||
ResourceTypeDto::Calendar => Resource::Calendar(dto.id),
|
||||
ResourceTypeDto::AddressBook => Resource::AddressBook(dto.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +89,8 @@ impl From<Resource> for ResourceDto {
|
||||
Resource::Folder(id) => (ResourceTypeDto::Folder, id),
|
||||
Resource::File(id) => (ResourceTypeDto::File, id),
|
||||
Resource::Drive(id) => (ResourceTypeDto::Drive, id),
|
||||
Resource::Calendar(id) => (ResourceTypeDto::Calendar, id),
|
||||
Resource::AddressBook(id) => (ResourceTypeDto::AddressBook, id),
|
||||
};
|
||||
ResourceDto { kind, id }
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
Resource::Folder(id) => ("Folder", id),
|
||||
Resource::File(id) => ("File", id),
|
||||
Resource::Drive(id) => ("Drive", id),
|
||||
Resource::Calendar(id) => ("Calendar", id),
|
||||
Resource::AddressBook(id) => ("AddressBook", id),
|
||||
};
|
||||
// Audit-worthy: denials are the interesting signal. Routed
|
||||
// through the `audit` tracing target so log aggregators can
|
||||
|
||||
@@ -307,20 +307,25 @@ impl MagicLinkInviteService {
|
||||
let (kind, resource_id) = match resource {
|
||||
Resource::Folder(id) => (MagicLinkResourceKind::Folder, id),
|
||||
Resource::File(id) => (MagicLinkResourceKind::File, id),
|
||||
// Drive sharing — and therefore drive magic-link invitations —
|
||||
// land in D2. The grant DTOs accept `Resource::Drive` from the
|
||||
// wire today (see ResourceTypeDto) but no public API path
|
||||
// actually grants on a drive in D0, so this arm is
|
||||
// defensively unreachable. Treating it as an audit-logged
|
||||
// no-op (grant is in place, mail suppressed) matches the
|
||||
// ineligible-recipient branch above.
|
||||
Resource::Drive(_) => {
|
||||
// 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
|
||||
// above.
|
||||
Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "magic_link.invitation_suppressed",
|
||||
reason = "drive_resource_unsupported",
|
||||
reason = "resource_kind_unsupported",
|
||||
user_id = %recipient.id(),
|
||||
"📭 magic-link invitation suppressed: drive resources aren't invitable until D2",
|
||||
resource_kind = %resource.type_str(),
|
||||
"📭 magic-link invitation suppressed: {} resources aren't invitable via email",
|
||||
resource.type_str(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -347,10 +352,12 @@ 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 a Drive resource. The arm exists only to satisfy
|
||||
// exhaustiveness; if you find this firing, the early-return
|
||||
// was bypassed.
|
||||
Resource::Drive(_) => "server.magic_link.email.kind_folder",
|
||||
// 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"
|
||||
}
|
||||
};
|
||||
// PR C: render in the recipient's preferred locale (set by UI
|
||||
// switcher, OIDC JIT claim, or inviter inheritance at row
|
||||
|
||||
@@ -472,11 +472,13 @@ impl RecipientNotificationService {
|
||||
let kind_key = match resource {
|
||||
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
||||
Resource::File(_) => "server.magic_link.email.kind_file",
|
||||
// Drives don't generate share notifications in D0 — drive
|
||||
// sharing lands in D2 and gets its own template key. Fall
|
||||
// back to the folder label so any path that does reach
|
||||
// here produces a readable, if generic, mail body.
|
||||
Resource::Drive(_) => "server.magic_link.email.kind_folder",
|
||||
// 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"
|
||||
}
|
||||
};
|
||||
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
|
||||
// Short form for the subject, long form (with email) for the
|
||||
|
||||
@@ -78,11 +78,20 @@ pub enum Resource {
|
||||
/// membership and policy bag. Added in D0; membership lives in
|
||||
/// `storage.role_grants` (no separate `drive_members` table).
|
||||
Drive(Uuid),
|
||||
// Reserved for future use:
|
||||
// Calendar(Uuid),
|
||||
// Reserved for future use:
|
||||
// AddressBook(Uuid),
|
||||
// Reserved for future use:
|
||||
/// A CalDAV calendar. Membership + sharing lives in
|
||||
/// `storage.role_grants` with `resource_type='calendar'` —
|
||||
/// replaces the pre-Round-3 dedicated `caldav.calendar_shares`
|
||||
/// table and the `check_calendar_access` bespoke helper. No
|
||||
/// cascade parent (calendars are top-level per user); the engine
|
||||
/// resolves directly against `role_grants` on the resource.
|
||||
Calendar(Uuid),
|
||||
/// A CardDAV address book. Same shape as `Calendar` —
|
||||
/// `storage.role_grants` with `resource_type='address_book'`
|
||||
/// 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),
|
||||
}
|
||||
|
||||
@@ -92,17 +101,19 @@ impl Resource {
|
||||
Resource::Folder(_) => "folder",
|
||||
Resource::File(_) => "file",
|
||||
Resource::Drive(_) => "drive",
|
||||
//Resource::Calendar(_) => "calendar",
|
||||
//Resource::AddressBook(_) => "adressbook",
|
||||
Resource::Calendar(_) => "calendar",
|
||||
Resource::AddressBook(_) => "address_book",
|
||||
//Resource::Playlist(_) => "playlist",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> Uuid {
|
||||
match self {
|
||||
Resource::Folder(id) | Resource::File(id) | Resource::Drive(id) => *id,
|
||||
//| Resource::Calendar(id)
|
||||
//| Resource::AddressBook(id)
|
||||
Resource::Folder(id)
|
||||
| Resource::File(id)
|
||||
| Resource::Drive(id)
|
||||
| Resource::Calendar(id)
|
||||
| Resource::AddressBook(id) => *id,
|
||||
//| Resource::Playlist(id)
|
||||
}
|
||||
}
|
||||
@@ -112,8 +123,8 @@ impl Resource {
|
||||
"folder" => Some(Resource::Folder(id)),
|
||||
"file" => Some(Resource::File(id)),
|
||||
"drive" => Some(Resource::Drive(id)),
|
||||
//"calendar" => Some(Resource::Calendar(id)),
|
||||
//"adressbook" => Some(Resource::AddressBook(id)),
|
||||
"calendar" => Some(Resource::Calendar(id)),
|
||||
"address_book" => Some(Resource::AddressBook(id)),
|
||||
//"playlist" => Some(Resource::Playlist(id)),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -479,11 +479,18 @@ impl PgAclEngine {
|
||||
/// Returns the `drive_id` for a File / Folder. Drives don't have a parent
|
||||
/// 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
|
||||
/// 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(_) => Err(DomainError::not_found("Drive", resource.id().to_string())),
|
||||
Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => Err(
|
||||
DomainError::not_found(resource.type_str(), resource.id().to_string()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,6 +526,49 @@ impl PgAclEngine {
|
||||
/// permission — see `roles_implying()`.
|
||||
///
|
||||
/// Uses the GiST index on `storage.folders.lpath` for O(log N) cascade.
|
||||
/// Direct grant lookup with no cascade — used for top-level
|
||||
/// resources whose ACL lives entirely on their own row
|
||||
/// (`Resource::Calendar`, `Resource::AddressBook`). Same
|
||||
/// role-array + subject-set shape as the cascade helpers so a
|
||||
/// caller's group memberships still resolve, but no ltree /
|
||||
/// folder ancestry / drive precheck applies. Calendars and
|
||||
/// address books have no parent to inherit from.
|
||||
async fn direct_grant_exists(
|
||||
&self,
|
||||
subject_types: &[&str],
|
||||
subject_ids: &[Uuid],
|
||||
permission: Permission,
|
||||
resource_type: &'static str,
|
||||
resource_id: Uuid,
|
||||
counters: &QueryCounters,
|
||||
) -> Result<bool, DomainError> {
|
||||
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||
let roles = Self::roles_implying_strings(permission);
|
||||
let exists: Option<i32> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT 1
|
||||
FROM storage.role_grants g
|
||||
WHERE g.subject_type = ANY($1)
|
||||
AND g.subject_id = ANY($2)
|
||||
AND g.role = ANY($3::storage.grant_role[])
|
||||
AND g.resource_type = $4
|
||||
AND g.resource_id = $5
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(subject_types)
|
||||
.bind(subject_ids)
|
||||
.bind(&roles)
|
||||
.bind(resource_type)
|
||||
.bind(resource_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("direct grant: {e}")))?;
|
||||
|
||||
Ok(exists.is_some())
|
||||
}
|
||||
|
||||
async fn folder_cascade_grant_exists(
|
||||
&self,
|
||||
subject_types: &[&str],
|
||||
@@ -819,6 +869,39 @@ impl PgAclEngine {
|
||||
.await?
|
||||
.is_some_and(|r| r.expand().contains(&permission)))
|
||||
}
|
||||
// Top-level resources with no cascade parent — the ACL
|
||||
// lives entirely on their own `role_grants` rows. Owner is
|
||||
// an explicit grant seeded at MKCALENDAR / address-book
|
||||
// create time (Round 3 phase 2 migration), so the common
|
||||
// "owner accessing their own calendar" case is one SQL
|
||||
// round-trip — no drive_role_cache short-circuit (no
|
||||
// drive), no cascade.
|
||||
Resource::Calendar(id) => {
|
||||
let (subject_types, subject_ids) =
|
||||
self.subject_match_set(subject, counters).await?;
|
||||
self.direct_grant_exists(
|
||||
&subject_types,
|
||||
&subject_ids,
|
||||
permission,
|
||||
"calendar",
|
||||
id,
|
||||
counters,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Resource::AddressBook(id) => {
|
||||
let (subject_types, subject_ids) =
|
||||
self.subject_match_set(subject, counters).await?;
|
||||
self.direct_grant_exists(
|
||||
&subject_types,
|
||||
&subject_ids,
|
||||
permission,
|
||||
"address_book",
|
||||
id,
|
||||
counters,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,14 @@ 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
|
||||
// drive; the default-empty bag is the right no-op here.
|
||||
Resource::Calendar(_) | Resource::AddressBook(_) => {
|
||||
Ok(crate::domain::entities::drive::DrivePolicies::default())
|
||||
}
|
||||
};
|
||||
let drive_policies = match drive_policies {
|
||||
Ok(p) => p,
|
||||
|
||||
Reference in New Issue
Block a user