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
3 changes: 3 additions & 0 deletions .ameba.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,6 @@ Style/PredicateName:

Style/QueryBoolMethods:
Enabled: false

Metrics/CyclomaticComplexity:
Enabled: false
188 changes: 188 additions & 0 deletions migration/db/migrations/20260423100500000_add_groups.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
-- +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.
-- ---------------------------------------------------------------------------
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 '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);

CREATE INDEX IF NOT EXISTS groups_authority_id_index
ON "groups" USING BTREE (authority_id);
CREATE INDEX IF NOT EXISTS groups_parent_id_index
ON "groups" USING BTREE (parent_id);
-- One root per authority
CREATE UNIQUE INDEX IF NOT EXISTS groups_authority_single_root
ON "groups" (authority_id) WHERE parent_id IS NULL;


-- ---------------------------------------------------------------------------
-- 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.
-- ---------------------------------------------------------------------------
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)
);

CREATE INDEX IF NOT EXISTS group_application_doorkeepers_doorkeeper_application_id_index
ON "group_application_doorkeepers" USING BTREE (doorkeeper_application_id);


-- ---------------------------------------------------------------------------
-- GroupUsers: junction between users and groups, with a permission bitmask.
-- Composite PK (user_id, group_id).
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS "group_users"(
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
group_id UUID NOT NULL REFERENCES "groups"(id) ON DELETE CASCADE,
permissions INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (user_id, group_id)
);

CREATE INDEX IF NOT EXISTS group_users_group_id_index
ON "group_users" USING BTREE (group_id);


-- ---------------------------------------------------------------------------
-- GroupZones: junction between groups and zones, with a permission bitmask
-- and a `deny` flag. A deny row removes access that would otherwise be
-- inherited from an ancestor zone. Composite PK (group_id, zone_id).
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS "group_zones"(
group_id UUID NOT NULL REFERENCES "groups"(id) ON DELETE CASCADE,
zone_id TEXT NOT NULL REFERENCES "zone"(id) ON DELETE CASCADE,
permissions INTEGER NOT NULL DEFAULT 0,
deny BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (group_id, zone_id)
);

CREATE INDEX IF NOT EXISTS group_zones_zone_id_index
ON "group_zones" USING BTREE (zone_id);


-- ---------------------------------------------------------------------------
-- GroupInvitations: grants access to a user not yet in the system (or external)
-- via a shared secret. Plaintext secret is generated + returned once at
-- creation time; only a SHA256 digest is stored. Optional expiry.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS "group_invitations"(
id UUID PRIMARY KEY DEFAULT uuidv7(),
group_id UUID NOT NULL REFERENCES "groups"(id) ON DELETE CASCADE,
email TEXT NOT NULL,
secret_digest TEXT NOT NULL,
permissions INTEGER NOT NULL DEFAULT 0,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);

CREATE INDEX IF NOT EXISTS group_invitations_group_id_index
ON "group_invitations" USING BTREE (group_id);
CREATE INDEX IF NOT EXISTS group_invitations_email_index
ON "group_invitations" USING BTREE (email);
CREATE UNIQUE INDEX IF NOT EXISTS group_invitations_secret_digest_unique
ON "group_invitations" (secret_digest);


-- ---------------------------------------------------------------------------
-- GroupHistory: audit trail for group-related changes. Written in the same
-- transaction as the triggering save (after_save / after_destroy callbacks).
--
-- user_id is the actor. When the actor user is later deleted, user_id is
-- set to NULL but `email` is preserved so the audit trail stays readable.
-- ---------------------------------------------------------------------------
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,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
changed_fields TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL
);

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
ON "group_history" USING BTREE (created_at);


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

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";
154 changes: 154 additions & 0 deletions spec/generator.cr
Original file line number Diff line number Diff line change
Expand Up @@ -798,5 +798,159 @@ module PlaceOS::Model
changed_fields: changed_fields
)
end

# -------------------------------------------------------------------
# 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)
unless authority
if parent
authority = Authority.find!(parent.authority_id)
else
existing = Authority.find_by_domain("localhost")
authority = existing || self.authority.save!
end
end
Group.new(
name: Faker::Hacker.noun + "-" + RANDOM.hex(3),
description: "",
authority_id: authority.id.not_nil!,
parent_id: parent.try(&.id),
)
end

def self.group_application_membership(
group : Group? = nil,
application : GroupApplication? = nil,
)
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!
end
DoorkeeperApplication.new(
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!,
)
end

def self.group_user(
user : User? = nil,
group : Group? = nil,
permissions : Permissions = Permissions::Read,
)
u = user || self.user.save!
g = group || self.group.save!
GroupUser.new(
user_id: u.id.not_nil!,
group_id: g.id.not_nil!,
permissions: permissions.to_i,
)
end

def self.group_zone(
group : Group? = nil,
zone : Zone? = nil,
permissions : Permissions = Permissions::Read,
deny : Bool = false,
)
g = group || self.group.save!
z = zone || self.zone.save!
GroupZone.new(
group_id: g.id.not_nil!,
zone_id: z.id.not_nil!,
permissions: permissions.to_i,
deny: deny,
)
end

def self.group_invitation(
group : Group? = nil,
email : String? = nil,
permissions : Permissions = Permissions::Read,
expires_at : Time? = nil,
)
g = group || self.group.save!
GroupInvitation.build_with_secret(
group: g,
email: email || Faker::Internet.email,
permissions: permissions,
expires_at: expires_at,
)
end

def self.group_history(
group_id : UUID? = nil,
application_id : UUID? = nil,
user : User? = nil,
action : String = "update",
resource_type : String = "group",
resource_id : String? = nil,
changed_fields : Array(String) = ["name"],
)
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,
action: action,
resource_type: resource_type,
resource_id: resource_id || "res-#{RANDOM.hex(4)}",
changed_fields: changed_fields,
)
end
end
end
Loading
Loading