Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 18 additions & 63 deletions migration/db/migrations/20260423100500000_add_groups.sql
Original file line number Diff line number Diff line change
@@ -1,38 +1,19 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied

-- ---------------------------------------------------------------------------
-- GroupApplication: a named subsystem (signage, events, parking, workplace, ...)
-- scoped to an Authority. Holds the root of a Group tree.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS "group_applications"(
id UUID PRIMARY KEY DEFAULT uuidv7(),
authority_id TEXT NOT NULL REFERENCES "authority"(id) ON DELETE CASCADE,
name TEXT NOT NULL,
code TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);

CREATE INDEX IF NOT EXISTS group_applications_authority_id_index
ON "group_applications" USING BTREE (authority_id);
CREATE UNIQUE INDEX IF NOT EXISTS group_applications_authority_code_unique
ON "group_applications" (authority_id, code);


-- ---------------------------------------------------------------------------
-- Groups: authority-wide tree (org hierarchy). A single root per authority is
-- enforced by a partial unique index. Membership in one or more applications
-- is modelled by `group_application_memberships` below, so the same group
-- subtree can be shared across subsystems.
-- enforced by a partial unique index. The `subsystems` text array names every
-- subsystem (signage, events, parking, ...) that a group participates in;
-- subsystem-scoped permission queries filter on `'<code>' = ANY(subsystems)`.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS "groups"(
id UUID PRIMARY KEY DEFAULT uuidv7(),
authority_id TEXT NOT NULL REFERENCES "authority"(id) ON DELETE CASCADE,
parent_id UUID REFERENCES "groups"(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
subsystems TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
Expand All @@ -44,45 +25,22 @@ CREATE INDEX IF NOT EXISTS groups_parent_id_index
-- One root per authority
CREATE UNIQUE INDEX IF NOT EXISTS groups_authority_single_root
ON "groups" (authority_id) WHERE parent_id IS NULL;
-- GIN index supports `'subsystem' = ANY(subsystems)` lookups efficiently.
CREATE INDEX IF NOT EXISTS groups_subsystems_index
ON "groups" USING GIN (subsystems);


-- ---------------------------------------------------------------------------
-- GroupApplicationMembership: M:N between groups and applications. A group
-- may participate in zero or more applications; an application sees only
-- grants attributed to groups in its membership list. Both sides must share
-- an authority (enforced at the model layer).
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS "group_application_memberships"(
group_id UUID NOT NULL REFERENCES "groups"(id) ON DELETE CASCADE,
application_id UUID NOT NULL REFERENCES "group_applications"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (group_id, application_id)
);

CREATE INDEX IF NOT EXISTS group_application_memberships_application_id_index
ON "group_application_memberships" USING BTREE (application_id);


-- ---------------------------------------------------------------------------
-- GroupApplicationDoorkeepers: links a GroupApplication to one or more
-- Doorkeeper (OAuth) applications so callers authenticating via a given
-- OAuth client can be resolved against this subsystem's permissions.
--
-- Both sides must share an authority (`doorkeeper.owner_id` ==
-- `group_application.authority_id`). Enforced at the model layer — the
-- database can't express that via a single FK.
-- DoorkeeperApplication.subsystems: same `TEXT[]` shape as `groups.subsystems`.
-- An OAuth client is associated with one or more subsystems — callers
-- authenticating via that client are resolved against those subsystems'
-- group permissions.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS "group_application_doorkeepers"(
group_application_id UUID NOT NULL REFERENCES "group_applications"(id) ON DELETE CASCADE,
doorkeeper_application_id BIGINT NOT NULL REFERENCES "oauth_applications"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (group_application_id, doorkeeper_application_id)
);
ALTER TABLE "oauth_applications"
ADD COLUMN IF NOT EXISTS subsystems TEXT[] NOT NULL DEFAULT '{}';

CREATE INDEX IF NOT EXISTS group_application_doorkeepers_doorkeeper_application_id_index
ON "group_application_doorkeepers" USING BTREE (doorkeeper_application_id);
CREATE INDEX IF NOT EXISTS oauth_applications_subsystems_index
ON "oauth_applications" USING GIN (subsystems);


-- ---------------------------------------------------------------------------
Expand Down Expand Up @@ -154,7 +112,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS group_invitations_secret_digest_unique
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS "group_history"(
id UUID PRIMARY KEY DEFAULT uuidv7(),
application_id UUID,
group_id UUID,
user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL,
email TEXT NOT NULL,
Expand All @@ -167,8 +124,6 @@ CREATE TABLE IF NOT EXISTS "group_history"(

CREATE INDEX IF NOT EXISTS group_history_group_id_index
ON "group_history" USING BTREE (group_id);
CREATE INDEX IF NOT EXISTS group_history_application_id_index
ON "group_history" USING BTREE (application_id);
CREATE INDEX IF NOT EXISTS group_history_user_id_index
ON "group_history" USING BTREE (user_id);
CREATE INDEX IF NOT EXISTS group_history_created_at_index
Expand All @@ -182,7 +137,7 @@ DROP TABLE IF EXISTS "group_history";
DROP TABLE IF EXISTS "group_invitations";
DROP TABLE IF EXISTS "group_zones";
DROP TABLE IF EXISTS "group_users";
DROP TABLE IF EXISTS "group_application_doorkeepers";
DROP TABLE IF EXISTS "group_application_memberships";
DROP TABLE IF EXISTS "groups";
DROP TABLE IF EXISTS "group_applications";

DROP INDEX IF EXISTS oauth_applications_subsystems_index;
ALTER TABLE "oauth_applications" DROP COLUMN IF EXISTS subsystems;
53 changes: 53 additions & 0 deletions migration/db/migrations/20260424100500000_add_signage_groups.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied

-- ---------------------------------------------------------------------------
-- GroupPlaylists: presence-only M:N junction between Groups
-- (authority-scoped) and Playlists (authority-scoped, legacy TEXT PK).
-- A row = "this group has access to this playlist". The user's actual
-- capability on a row is the user's `GroupUser.permissions` within the
-- group — this junction does not carry its own bitmask.
--
-- Authority-match between `group` and `playlist` is enforced at the
-- model layer (the DB can't express it via a single FK).
--
-- "Playlists with zero junction rows are sys_admin/support-only" is a
-- REST-layer rule, not a DB invariant — the schema just allows zero or
-- more groups per playlist.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS "group_playlists"(
group_id UUID NOT NULL REFERENCES "groups"(id) ON DELETE CASCADE,
playlist_id TEXT NOT NULL REFERENCES "playlists"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (group_id, playlist_id)
);

CREATE INDEX IF NOT EXISTS group_playlists_playlist_id_index
ON "group_playlists" USING BTREE (playlist_id);


-- ---------------------------------------------------------------------------
-- GroupPlaylistItems: same shape as group_playlists, but against
-- Playlist::Item rows (individual media / plugin / webpage items).
-- Items with no group junction rows are sys_admin/support-only
-- (enforced in the REST layer). As with group_playlists, the user's
-- capability is their GroupUser.permissions, not a per-row bitmask.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS "group_playlist_items"(
group_id UUID NOT NULL REFERENCES "groups"(id) ON DELETE CASCADE,
playlist_item_id TEXT NOT NULL REFERENCES "playlist_items"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (group_id, playlist_item_id)
);

CREATE INDEX IF NOT EXISTS group_playlist_items_playlist_item_id_index
ON "group_playlist_items" USING BTREE (playlist_item_id);


-- +micrate Down
-- SQL section 'Down' is executed when this migration is rolled back

DROP TABLE IF EXISTS "group_playlist_items";
DROP TABLE IF EXISTS "group_playlists";
107 changes: 51 additions & 56 deletions spec/generator.cr
Original file line number Diff line number Diff line change
Expand Up @@ -803,20 +803,11 @@ module PlaceOS::Model
# Group permissions system
# -------------------------------------------------------------------

def self.group_application(authority : Authority? = nil, code : String? = nil)
unless authority
existing = Authority.find_by_domain("localhost")
authority = existing || self.authority.save!
end
GroupApplication.new(
name: Faker::Hacker.noun,
code: code || "#{Faker::Hacker.noun}-#{RANDOM.hex(3)}",
description: Faker::Hacker.say_something_smart,
authority_id: authority.id.not_nil!,
)
end

def self.group(authority : Authority? = nil, parent : Group? = nil)
def self.group(
authority : Authority? = nil,
parent : Group? = nil,
subsystems : Array(String) = [] of String,
)
unless authority
if parent
authority = Authority.find!(parent.authority_id)
Expand All @@ -830,30 +821,15 @@ module PlaceOS::Model
description: "",
authority_id: authority.id.not_nil!,
parent_id: parent.try(&.id),
subsystems: subsystems,
)
end

def self.group_application_membership(
group : Group? = nil,
application : GroupApplication? = nil,
def self.doorkeeper_application(
owner : Authority? = nil,
name : String? = nil,
subsystems : Array(String) = [] of String,
)
authority = if group
Authority.find!(group.authority_id)
elsif application
Authority.find!(application.authority_id)
else
existing = Authority.find_by_domain("localhost")
existing || self.authority.save!
end
g = group || self.group(authority: authority).save!
a = application || self.group_application(authority: authority).save!
GroupApplicationMembership.new(
group_id: g.id.not_nil!,
application_id: a.id.not_nil!,
)
end

def self.doorkeeper_application(owner : Authority? = nil, name : String? = nil)
owner_auth = owner || begin
existing = Authority.find_by_domain("localhost")
existing || self.authority.save!
Expand All @@ -862,26 +838,7 @@ module PlaceOS::Model
name: name || "oauth-#{RANDOM.hex(6)}",
redirect_uri: "http://example.com/callback/#{RANDOM.hex(4)}",
owner_id: owner_auth.id.not_nil!,
)
end

def self.group_application_doorkeeper(
group_application : GroupApplication? = nil,
doorkeeper_application : DoorkeeperApplication? = nil,
)
authority = if group_application
Authority.find!(group_application.authority_id)
elsif doorkeeper_application
Authority.find!(doorkeeper_application.owner_id)
else
existing = Authority.find_by_domain("localhost")
existing || self.authority.save!
end
ga = group_application || self.group_application(authority: authority).save!
da = doorkeeper_application || self.doorkeeper_application(owner: authority).save!
GroupApplicationDoorkeeper.new(
group_application_id: ga.id.not_nil!,
doorkeeper_application_id: da.id.not_nil!,
subsystems: subsystems,
)
end

Expand Down Expand Up @@ -932,7 +889,6 @@ module PlaceOS::Model

def self.group_history(
group_id : UUID? = nil,
application_id : UUID? = nil,
user : User? = nil,
action : String = "update",
resource_type : String = "group",
Expand All @@ -942,7 +898,6 @@ module PlaceOS::Model
actor_email = user.try(&.email.to_s) || Faker::Internet.email
actor_id = user.try(&.id)
GroupHistory.new(
application_id: application_id,
group_id: group_id,
user_id: actor_id,
email: actor_email,
Expand All @@ -952,5 +907,45 @@ module PlaceOS::Model
changed_fields: changed_fields,
)
end

def self.group_playlist(
group : Group? = nil,
playlist : Playlist? = nil,
)
authority = if group
Authority.find!(group.authority_id)
elsif playlist
Authority.find!(playlist.authority_id.not_nil!)
else
existing = Authority.find_by_domain("localhost")
existing || self.authority.save!
end
g = group || self.group(authority: authority).save!
pl = playlist || self.playlist(authority: authority).save!
GroupPlaylist.new(
group_id: g.id.not_nil!,
playlist_id: pl.id.not_nil!,
)
end

def self.group_playlist_item(
group : Group? = nil,
playlist_item : Playlist::Item? = nil,
)
authority = if group
Authority.find!(group.authority_id)
elsif playlist_item
Authority.find!(playlist_item.authority_id.not_nil!)
else
existing = Authority.find_by_domain("localhost")
existing || self.authority.save!
end
g = group || self.group(authority: authority).save!
pi = playlist_item || self.item(authority: authority).save!
GroupPlaylistItem.new(
group_id: g.id.not_nil!,
playlist_item_id: pi.id.not_nil!,
)
end
end
end
Loading
Loading