diff --git a/shard.lock b/shard.lock index e4a0f804..f7400d89 100644 --- a/shard.lock +++ b/shard.lock @@ -159,7 +159,7 @@ shards: office365: git: https://github.com/placeos/office365.git - version: 1.26.1 + version: 1.27.1 openai: git: https://github.com/spider-gazelle/crystal-openai.git @@ -211,7 +211,7 @@ shards: placeos-frontend-loader: git: https://github.com/placeos/frontend-loader.git - version: 2.7.1+git.commit.f6a6fb1b2d6f22f5fd312c1247ffce911cf735bb + version: 2.7.1+git.commit.c342bf628278dfd419ff91d6bb033287caa131a4 placeos-log-backend: git: https://github.com/place-labs/log-backend.git @@ -219,11 +219,11 @@ shards: placeos-models: git: https://github.com/placeos/models.git - version: 9.87.0 + version: 9.88.0 placeos-resource: git: https://github.com/place-labs/resource.git - version: 3.1.1 + version: 3.1.2 pool: git: https://github.com/ysbaddaden/pool.git @@ -267,7 +267,7 @@ shards: search-ingest: git: https://github.com/placeos/search-ingest.git - version: 2.11.3+git.commit.bf3c6a409e8e5685adb42fb7d2a3b2ba8b32c24b + version: 2.11.3+git.commit.8d1aa8dfba78cdabd4d8e65181df82d4a1557537 secrets-env: # Overridden git: https://github.com/spider-gazelle/secrets-env.git diff --git a/spec/controllers/groups/application_doorkeeper_spec.cr b/spec/controllers/groups/application_doorkeeper_spec.cr new file mode 100644 index 00000000..0ee503df --- /dev/null +++ b/spec/controllers/groups/application_doorkeeper_spec.cr @@ -0,0 +1,34 @@ +require "../../helper" + +module PlaceOS::Api + describe Groups::ApplicationDoorkeepers do + base = Groups::ApplicationDoorkeepers.base_route + + ::Spec.before_each { clear_group_tables } + + it "sys_admin can create and destroy a link; non-admin cannot list, create or destroy" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + app = Model::Generator.group_application(authority: authority).save! + doorkeeper = Model::Generator.doorkeeper_application(owner: authority).save! + + payload = { + group_application_id: app.id, + doorkeeper_application_id: doorkeeper.id, + }.to_json + + _, user_headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + client.get(base, headers: user_headers).status_code.should eq 403 + client.post(base, body: payload, headers: user_headers).status_code.should eq 403 + + create = client.post(base, body: payload, headers: Spec::Authentication.headers) + create.status_code.should eq 201 + + show_path = File.join(base, app.id.to_s, doorkeeper.id.to_s) + client.get(show_path, headers: Spec::Authentication.headers).status_code.should eq 200 + + delete = client.delete(show_path, headers: Spec::Authentication.headers) + delete.success?.should be_true + end + end +end diff --git a/spec/controllers/groups/application_membership_spec.cr b/spec/controllers/groups/application_membership_spec.cr new file mode 100644 index 00000000..d342dcf3 --- /dev/null +++ b/spec/controllers/groups/application_membership_spec.cr @@ -0,0 +1,34 @@ +require "../../helper" + +module PlaceOS::Api + describe Groups::ApplicationMemberships do + base = Groups::ApplicationMemberships.base_route + + ::Spec.before_each { clear_group_tables } + + it "sys_admin can create and destroy; non-admin cannot" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + app = Model::Generator.group_application(authority: authority).save! + group = Model::Generator.group(authority: authority).save! + + payload = {group_id: group.id, application_id: app.id}.to_json + + _, user_headers = Spec::Authentication.authentication(sys_admin: false, support: false) + forbidden = client.post(base, body: payload, headers: user_headers) + forbidden.status_code.should eq 403 + + create = client.post(base, body: payload, headers: Spec::Authentication.headers) + create.status_code.should eq 201 + + show_path = File.join(base, group.id.to_s, app.id.to_s) + show = client.get(show_path, headers: user_headers) + show.status_code.should eq 200 + + delete_forbidden = client.delete(show_path, headers: user_headers) + delete_forbidden.status_code.should eq 403 + + delete = client.delete(show_path, headers: Spec::Authentication.headers) + delete.success?.should be_true + end + end +end diff --git a/spec/controllers/groups/application_spec.cr b/spec/controllers/groups/application_spec.cr new file mode 100644 index 00000000..22c65eeb --- /dev/null +++ b/spec/controllers/groups/application_spec.cr @@ -0,0 +1,132 @@ +require "../../helper" + +module PlaceOS::Api + describe Groups::Applications do + base = Groups::Applications.base_route + + ::Spec.before_each { clear_group_tables } + + describe "CRUD (sys_admin)" do + it "indexes + shows for any authenticated user" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + app = Model::Generator.group_application(authority: authority).save! + + _, user_headers = Spec::Authentication.authentication(sys_admin: false, support: false) + result = client.get(base, headers: user_headers) + result.status_code.should eq 200 + Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"]).should contain(JSON::Any.new(app.id.to_s)) + + show = client.get(File.join(base, app.id.to_s), headers: user_headers) + show.status_code.should eq 200 + end + + it "rejects create for non-sys_admin" do + _, user_headers = Spec::Authentication.authentication(sys_admin: false, support: false) + authority = Model::Authority.find_by_domain("localhost").not_nil! + payload = Model::Generator.group_application(authority: authority).to_json + result = client.post(base, body: payload, headers: user_headers) + result.status_code.should eq 403 + end + + it "allows create/update/destroy for sys_admin" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + payload = Model::Generator.group_application(authority: authority).to_json + create = client.post(base, body: payload, headers: Spec::Authentication.headers) + create.status_code.should eq 201 + created = Model::GroupApplication.from_trusted_json(create.body) + + update = client.patch( + File.join(base, created.id.to_s), + body: {name: "renamed"}.to_json, + headers: Spec::Authentication.headers, + ) + update.status_code.should eq 200 + + delete = client.delete(File.join(base, created.id.to_s), headers: Spec::Authentication.headers) + delete.success?.should be_true + Model::GroupApplication.find?(created.id.not_nil!).should be_nil + end + end + + it "index supports ?q= substring search on name" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + signage = Model::Generator.group_application(authority: authority) + signage.name = "Signage-#{Random::Secure.hex(3)}" + signage.save! + events = Model::Generator.group_application(authority: authority) + events.name = "Events-#{Random::Secure.hex(3)}" + events.save! + + result = client.get("#{base}?q=signage", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map { |e| e["id"].as_s } + ids.should contain(signage.id.to_s) + ids.should_not contain(events.id.to_s) + end + + describe "helper routes" do + it "effective_permissions returns the user's bitmask on a zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + app = Model::Generator.group_application(authority: authority).save! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + root_group = Model::Generator.group(authority: authority).save! + Model::Generator.group_application_membership(group: root_group, application: app).save! + Model::Generator.group_user(user: user, group: root_group, permissions: Model::Permissions::Read).save! + + zone = Model::Generator.zone.save! + Model::Generator.group_zone(group: root_group, zone: zone, permissions: Model::Permissions::Read).save! + + result = client.get( + "#{File.join(base, app.id.to_s)}/effective_permissions?zone_id=#{zone.id}", + headers: headers, + ) + result.status_code.should eq 200 + body = JSON.parse(result.body) + body["permissions"].as_i.should eq Model::Permissions::Read.to_i + body["zone_ids"].as_a.map(&.as_s).should eq [zone.id] + end + + it "effective_permissions supports batch zone_ids" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + app = Model::Generator.group_application(authority: authority).save! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + root_group = Model::Generator.group(authority: authority).save! + Model::Generator.group_application_membership(group: root_group, application: app).save! + Model::Generator.group_user(user: user, group: root_group, permissions: Model::Permissions::All).save! + + z1 = Model::Generator.zone.save! + z2 = Model::Generator.zone.save! + Model::Generator.group_zone(group: root_group, zone: z1, permissions: Model::Permissions::Read).save! + Model::Generator.group_zone(group: root_group, zone: z2, permissions: Model::Permissions::Update).save! + + result = client.get( + "#{File.join(base, app.id.to_s)}/effective_permissions?zone_ids=#{z1.id},#{z2.id}", + headers: headers, + ) + result.status_code.should eq 200 + body = JSON.parse(result.body) + body["permissions"].as_i.should eq (Model::Permissions::Read | Model::Permissions::Update).to_i + end + + it "accessible_zones lists every reachable zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + app = Model::Generator.group_application(authority: authority).save! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + root_group = Model::Generator.group(authority: authority).save! + Model::Generator.group_application_membership(group: root_group, application: app).save! + Model::Generator.group_user(user: user, group: root_group, permissions: Model::Permissions::Read).save! + + zone = Model::Generator.zone.save! + Model::Generator.group_zone(group: root_group, zone: zone, permissions: Model::Permissions::Read).save! + + result = client.get("#{File.join(base, app.id.to_s)}/accessible_zones", headers: headers) + result.status_code.should eq 200 + body = JSON.parse(result.body) + body["zone_ids"].as_a.map(&.as_s).should contain(zone.id.not_nil!) + end + end + end +end diff --git a/spec/controllers/groups/history_spec.cr b/spec/controllers/groups/history_spec.cr new file mode 100644 index 00000000..7b05abbf --- /dev/null +++ b/spec/controllers/groups/history_spec.cr @@ -0,0 +1,31 @@ +require "../../helper" + +module PlaceOS::Api + describe Groups::History do + base = Groups::History.base_route + + ::Spec.before_each { clear_group_tables } + + it "sys_admin can list all history; non-admin must scope to a managed group" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + admin = Spec::Authentication.user(sys_admin: true) + manager, manager_headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + group = Model::Generator.group(authority: authority) + group.acting_user = admin + group.save! + + Model::Generator.group_user(user: manager, group: group, permissions: Model::Permissions::Manage).save! + + # non-admin with no group_id → 403 + client.get(base, headers: manager_headers).status_code.should eq 403 + + # non-admin with group_id they manage → 200 + scoped = client.get("#{base}?group_id=#{group.id}", headers: manager_headers) + scoped.status_code.should eq 200 + + # sys_admin unfiltered → 200 + client.get(base, headers: Spec::Authentication.headers).status_code.should eq 200 + end + end +end diff --git a/spec/controllers/groups/invitation_spec.cr b/spec/controllers/groups/invitation_spec.cr new file mode 100644 index 00000000..176d1052 --- /dev/null +++ b/spec/controllers/groups/invitation_spec.cr @@ -0,0 +1,65 @@ +require "../../helper" + +module PlaceOS::Api + describe Groups::Invitations do + base = Groups::Invitations.base_route + + ::Spec.before_each { clear_group_tables } + + it "manager can create and destroy invitations; response includes plaintext_secret once" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + manager, manager_headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + group = Model::Generator.group(authority: authority).save! + Model::Generator.group_user(user: manager, group: group, permissions: Model::Permissions::Manage).save! + + payload = { + group_id: group.id, + email: "invited-#{Random::Secure.hex(4)}@example.com", + permissions: Model::Permissions::Read.to_i, + }.to_json + create = client.post(base, body: payload, headers: manager_headers) + create.status_code.should eq 201 + + body = JSON.parse(create.body) + body["plaintext_secret"].as_s.size.should be > 10 + id = body["invitation"]["id"].as_s + + delete = client.delete(File.join(base, id), headers: manager_headers) + delete.success?.should be_true + end + + it "rejects invitation creation without Manage" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + _, user_headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + group = Model::Generator.group(authority: authority).save! + payload = { + group_id: group.id, + email: "invited-#{Random::Secure.hex(4)}@example.com", + permissions: 1, + }.to_json + client.post(base, body: payload, headers: user_headers).status_code.should eq 403 + end + + it "user can accept an invitation they're eligible for" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + group = Model::Generator.group(authority: authority).save! + invitation = Model::GroupInvitation.build_with_secret( + group: group, + email: user.email.to_s, + permissions: Model::Permissions::Read, + ) + invitation.save! + + result = client.post(File.join(base, invitation.id.to_s, "accept"), headers: headers) + result.status_code.should eq 200 + gu = Model::GroupUser.from_trusted_json(result.body) + gu.user_id.should eq user.id + gu.group_id.should eq group.id + Model::GroupInvitation.find?(invitation.id.not_nil!).should be_nil + end + end +end diff --git a/spec/controllers/groups/user_spec.cr b/spec/controllers/groups/user_spec.cr new file mode 100644 index 00000000..e5077db7 --- /dev/null +++ b/spec/controllers/groups/user_spec.cr @@ -0,0 +1,53 @@ +require "../../helper" + +module PlaceOS::Api + describe Groups::Users do + base = Groups::Users.base_route + + ::Spec.before_each { clear_group_tables } + + it "manager can add and remove a user on their group" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + manager, manager_headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + group = Model::Generator.group(authority: authority).save! + Model::Generator.group_user(user: manager, group: group, permissions: Model::Permissions::Manage).save! + + other = Model::Generator.user(authority: authority).save! + payload = { + user_id: other.id, + group_id: group.id, + permissions: Model::Permissions::Read.to_i, + }.to_json + + create = client.post(base, body: payload, headers: manager_headers) + create.status_code.should eq 201 + + delete_path = File.join(base, other.id.to_s, group.id.to_s) + delete = client.delete(delete_path, headers: manager_headers) + delete.success?.should be_true + end + + it "non-manager rejected when managing another user's entry" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + _, user_headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + group = Model::Generator.group(authority: authority).save! + other = Model::Generator.user(authority: authority).save! + payload = {user_id: other.id, group_id: group.id, permissions: 1}.to_json + client.post(base, body: payload, headers: user_headers).status_code.should eq 403 + end + + it "user can see their own entries via user_id=me" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + group = Model::Generator.group(authority: authority).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + + result = client.get("#{base}?user_id=me", headers: headers) + result.status_code.should eq 200 + rows = Array(Hash(String, JSON::Any)).from_json(result.body) + rows.map { |r| r["user_id"].as_s }.uniq.should eq [user.id.to_s] + end + end +end diff --git a/spec/controllers/groups/zone_spec.cr b/spec/controllers/groups/zone_spec.cr new file mode 100644 index 00000000..70378d6c --- /dev/null +++ b/spec/controllers/groups/zone_spec.cr @@ -0,0 +1,57 @@ +require "../../helper" + +module PlaceOS::Api + describe Groups::Zones do + base = Groups::Zones.base_route + + ::Spec.before_each { clear_group_tables } + + it "manager can delegate a zone they already have coverage on" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + app = Model::Generator.group_application(authority: authority).save! + manager, manager_headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + parent_group = Model::Generator.group(authority: authority).save! + child_group = Model::Generator.group(authority: authority, parent: parent_group).save! + [parent_group, child_group].each do |g| + Model::Generator.group_application_membership(group: g, application: app).save! + end + Model::Generator.group_user(user: manager, group: parent_group, permissions: Model::Permissions::Manage).save! + + zone = Model::Generator.zone.save! + Model::Generator.group_zone(group: parent_group, zone: zone, permissions: Model::Permissions::All).save! + + # Manager delegates the (already-covered) zone to the child group. + payload = { + group_id: child_group.id, + zone_id: zone.id, + permissions: Model::Permissions::Read.to_i, + }.to_json + result = client.post(base, body: payload, headers: manager_headers) + result.status_code.should eq 201 + end + + it "manager cannot delegate a zone they have no coverage on" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + app = Model::Generator.group_application(authority: authority).save! + manager, manager_headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + parent_group = Model::Generator.group(authority: authority).save! + child_group = Model::Generator.group(authority: authority, parent: parent_group).save! + [parent_group, child_group].each do |g| + Model::Generator.group_application_membership(group: g, application: app).save! + end + Model::Generator.group_user(user: manager, group: parent_group, permissions: Model::Permissions::Manage).save! + + # Manager has no grant for this zone at all. + unreachable = Model::Generator.zone.save! + payload = { + group_id: child_group.id, + zone_id: unreachable.id, + permissions: Model::Permissions::Read.to_i, + }.to_json + result = client.post(base, body: payload, headers: manager_headers) + result.status_code.should eq 403 + end + end +end diff --git a/spec/controllers/groups_spec.cr b/spec/controllers/groups_spec.cr new file mode 100644 index 00000000..5cf1c7c9 --- /dev/null +++ b/spec/controllers/groups_spec.cr @@ -0,0 +1,94 @@ +require "../helper" + +module PlaceOS::Api + describe Groups do + base = Groups.base_route + + ::Spec.before_each { clear_group_tables } + + it "sys_admin can create a root group; non-admin cannot" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + payload = Model::Generator.group(authority: authority).to_json + + # non-admin rejected + _, user_headers = Spec::Authentication.authentication(sys_admin: false, support: false) + forbidden = client.post(base, body: payload, headers: user_headers) + forbidden.status_code.should eq 403 + + # sys_admin OK + result = client.post(base, body: payload, headers: Spec::Authentication.headers) + result.status_code.should eq 201 + created = Model::Group.from_trusted_json(result.body) + Model::Group.find?(created.id.not_nil!).should_not be_nil + end + + it "a manager can create a child group under their managed root" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + root = Model::Generator.group(authority: authority).save! + Model::Generator.group_user(user: user, group: root, permissions: Model::Permissions::Manage).save! + + child_payload = Model::Generator.group(authority: authority, parent: root).to_json + result = client.post(base, body: child_payload, headers: headers) + result.status_code.should eq 201 + end + + it "a non-manager cannot create a child group" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + _, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + root = Model::Generator.group(authority: authority).save! + child_payload = Model::Generator.group(authority: authority, parent: root).to_json + result = client.post(base, body: child_payload, headers: headers) + result.status_code.should eq 403 + end + + it "#current returns groups the user belongs to with permissions" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + root = Model::Generator.group(authority: authority).save! + Model::Generator.group_user(user: user, group: root, permissions: Model::Permissions::Read).save! + + result = client.get(File.join(base, "current"), headers: headers) + result.status_code.should eq 200 + entries = Array(Hash(String, JSON::Any)).from_json(result.body) + entries.map { |e| e["group"]["id"].as_s }.should contain(root.id.to_s) + entries.first["permissions"].as_i.should eq Model::Permissions::Read.to_i + end + + it "index supports ?q= substring search on name (sys_admin)" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + alpha = Model::Generator.group(authority: authority) + alpha.name = "Engineering-alpha-#{Random::Secure.hex(3)}" + alpha.save! + beta = Model::Generator.group(authority: authority, parent: alpha) + beta.name = "Beta-team-#{Random::Secure.hex(3)}" + beta.save! + + result = client.get("#{base}?q=engineering", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map { |e| e["id"].as_s } + ids.should contain(alpha.id.to_s) + ids.should_not contain(beta.id.to_s) + end + + it "index scopes non-admin callers to their own memberships (direct + transitive)" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + mine = Model::Generator.group(authority: authority).save! + Model::Generator.group_user(user: user, group: mine, permissions: Model::Permissions::Read).save! + child = Model::Generator.group(authority: authority, parent: mine).save! + + # Create a sibling root (new authority) to prove cross-authority is not leaking. + other_authority = Model::Generator.authority(domain: "http://other-#{Random::Secure.hex(3)}.example").save! + _unrelated = Model::Generator.group(authority: other_authority).save! + + result = client.get(base, headers: headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map { |e| e["id"].as_s } + ids.sort!.should eq [mine.id.to_s, child.id.to_s].sort! + end + end +end diff --git a/spec/helper.cr b/spec/helper.cr index e46fcf03..ca0aecc5 100644 --- a/spec/helper.cr +++ b/spec/helper.cr @@ -49,6 +49,20 @@ require "placeos-models/spec/generator" PgORM::Database.configure { |_| } def clear_tables + # Clear group-system tables first — they FK into User/Authority/Zone + # and must be dropped before their parents. Done sequentially since + # some of them reference each other. + [ + PlaceOS::Model::GroupHistory, + PlaceOS::Model::GroupInvitation, + PlaceOS::Model::GroupZone, + PlaceOS::Model::GroupUser, + PlaceOS::Model::GroupApplicationDoorkeeper, + PlaceOS::Model::GroupApplicationMembership, + PlaceOS::Model::Group, + PlaceOS::Model::GroupApplication, + ].each(&.clear) + {% begin %} Promise.all( {% for t in { @@ -70,6 +84,7 @@ def clear_tables PlaceOS::Model::Metadata, PlaceOS::Model::Upload, PlaceOS::Model::Storage, + PlaceOS::Model::DoorkeeperApplication, } %} Promise.defer { {{t.id}}.clear }, {% end %} @@ -117,6 +132,24 @@ def random_name UUID.random.to_s.split('-').first end +# Clears just the group-system tables. Tests sharing the seeded +# `localhost` authority need this in `before_each` because group creation +# enforces a one-root-per-authority rule — without it the second test +# that creates a root group fails with a validation error. +def clear_group_tables + [ + PlaceOS::Model::GroupHistory, + PlaceOS::Model::GroupInvitation, + PlaceOS::Model::GroupZone, + PlaceOS::Model::GroupUser, + PlaceOS::Model::GroupApplicationDoorkeeper, + PlaceOS::Model::GroupApplicationMembership, + PlaceOS::Model::Group, + PlaceOS::Model::GroupApplication, + PlaceOS::Model::DoorkeeperApplication, + ].each(&.clear) +end + def refresh_elastic(index : String? = nil) path = "/_refresh" path = "/#{index}" + path unless index.nil? diff --git a/src/placeos-rest-api/controllers/application.cr b/src/placeos-rest-api/controllers/application.cr index 1114645a..8b556887 100644 --- a/src/placeos-rest-api/controllers/application.cr +++ b/src/placeos-rest-api/controllers/application.cr @@ -76,6 +76,34 @@ module PlaceOS::Api response.headers["Content-Range"] = "#{content_type} 0-#{size - 1}/#{size}" end + # SQL-based pagination for models that aren't Elasticsearch-indexed. + # Accepts any PgORM relation (`Model.where(...)` etc.) and returns + # the requested page. Sets `X-Total-Count`, `Content-Range`, and + # `Link` response headers the same way `paginate_results` does for + # Elasticsearch queries, so clients can paginate uniformly. + def paginate_sql( + query, + type : String, + limit : Int32 = 100, + offset : Int32 = 0, + route : String = base_route, + ) + total = query.count.to_i32 + results = query.offset(offset).limit(limit).to_a + + range_end = offset + results.size + response.headers["X-Total-Count"] = total.to_s + response.headers["Content-Range"] = "#{type} #{offset}-#{range_end}/#{total}" + + if range_end < total + query_params["offset"] = (range_end + 1).to_s + query_params["limit"] = limit.to_s + response.headers["Link"] = %(<#{route}?#{query_params}>; rel="next") + end + + results + end + getter! search_params : Hash(String, String | Array(String)) @[AC::Route::Filter(:before_action, only: [:index], converters: {fields: ConvertStringArray})] diff --git a/src/placeos-rest-api/controllers/groups.cr b/src/placeos-rest-api/controllers/groups.cr new file mode 100644 index 00000000..37f5535e --- /dev/null +++ b/src/placeos-rest-api/controllers/groups.cr @@ -0,0 +1,183 @@ +require "placeos-models/group" +require "placeos-models/permissions" + +require "./application" + +module PlaceOS::Api + class Groups < Application + include Utils::GroupPermissions + + base "/api/engine/v2/groups/" + + # Scopes + ############################################################################################### + + before_action :can_read, only: [:index, :show, :current] + before_action :can_write, only: [:create, :update, :destroy] + + ############################################################################################### + + @[AC::Route::Filter(:before_action, except: [:index, :create, :current])] + def find_current_group(id : String) + Log.context.set(group_id: id) + @current_group = ::PlaceOS::Model::Group.find!(UUID.new(id)) + end + + getter! current_group : ::PlaceOS::Model::Group + + @[AC::Route::Filter(:before_action, only: [:update, :create], body: :group_update)] + def parse_update_group(@group_update : ::PlaceOS::Model::Group) + end + + getter! group_update : ::PlaceOS::Model::Group + + # Permission gates + ############################################################################################### + + @[AC::Route::Filter(:before_action, only: [:show])] + def check_show_permissions + return if user_admin? + ensure_member!(current_user, current_group) + end + + @[AC::Route::Filter(:before_action, only: [:create])] + def check_create_permissions + return if user_admin? + # Root groups (parent_id nil) can only be created by sys_admin. + parent_id = group_update.parent_id + raise Error::Forbidden.new("only sys_admin may create a root group") if parent_id.nil? + parent = ::PlaceOS::Model::Group.find!(parent_id) + ensure_manage!(current_user, parent) + end + + @[AC::Route::Filter(:before_action, only: [:update])] + def check_update_permissions + return if user_admin? + ensure_manage!(current_user, current_group) + # Block reparenting unless manager also has Manage on the new parent. + new_parent_id = group_update.parent_id + if new_parent_id && new_parent_id != current_group.parent_id + new_parent = ::PlaceOS::Model::Group.find!(new_parent_id) + ensure_manage!(current_user, new_parent) + end + end + + @[AC::Route::Filter(:before_action, only: [:destroy])] + def check_destroy_permissions + return if user_admin? + # Can't delete a root group unless sys_admin. + parent_id = current_group.parent_id + raise Error::Forbidden.new("only sys_admin may delete a root group") if parent_id.nil? + parent = ::PlaceOS::Model::Group.find!(parent_id) + ensure_manage!(current_user, parent) + end + + ############################################################################################### + + # List groups. sys_admin sees all within the current authority; other + # users see only groups they're a member of (direct or transitive). + # Optionally filter by `parent_id` for tree queries (pass the + # literal string `root` for root-level groups). Pass `q` for a + # case-insensitive substring search on `name`. + @[AC::Route::GET("/")] + def index( + @[AC::Param::Info(description: "filter by parent group id; pass 'root' for root-level groups")] + parent_id : String? = nil, + @[AC::Param::Info(description: "case-insensitive substring search on name (SQL ILIKE)")] + q : String? = nil, + limit : Int32 = 100, + offset : Int32 = 0, + ) : Array(::PlaceOS::Model::Group) + authority_id = current_authority.as(::PlaceOS::Model::Authority).id.as(String) + query = ::PlaceOS::Model::Group.where(authority_id: authority_id) + + unless user_admin? + ids = viewable_group_ids(current_user) + return [] of ::PlaceOS::Model::Group if ids.empty? + query = query.where(id: ids) + end + + if parent_id + if parent_id == "root" + query = query.where(parent_id: nil) + else + query = query.where(parent_id: UUID.new(parent_id)) + end + end + + if (term = q) && !term.empty? + query = query.where("name ILIKE ?", "%#{term}%") + end + + paginate_sql(query, type: "groups", limit: limit, offset: offset) + end + + # Show group details. Visible if sys_admin or a member. + @[AC::Route::GET("/:id")] + def show : ::PlaceOS::Model::Group + current_group + end + + # Update the group (name / description / parent). + @[AC::Route::PATCH("/:id")] + @[AC::Route::PUT("/:id")] + def update : ::PlaceOS::Model::Group + update = group_update + current = current_group + current.assign_attributes(update) + current.acting_user = current_user + raise Error::ModelValidation.new(current.errors) unless current.save + current + end + + # Create a new group. A root group (parent_id nil) requires + # sys_admin; a child group requires Manage on the parent. + @[AC::Route::POST("/", status_code: HTTP::Status::CREATED)] + def create : ::PlaceOS::Model::Group + group = group_update + # Force the authority to the caller's — no cross-authority moves. + group.authority_id = current_authority.as(::PlaceOS::Model::Authority).id.as(String) + group.acting_user = current_user + raise Error::ModelValidation.new(group.errors) unless group.save + group + end + + # Destroy a group (cascades children and junctions via FK). + @[AC::Route::DELETE("/:id", status_code: HTTP::Status::ACCEPTED)] + def destroy : Nil + group = current_group + group.acting_user = current_user + group.destroy + end + + # Groups the current user is a member of (direct or via ancestor), + # within the current authority. Lightweight — returns the group + # rows plus the user's effective permission bitmask per group. + struct CurrentGroup + include JSON::Serializable + + getter group : ::PlaceOS::Model::Group + getter permissions : Int32 + + def initialize(@group, @permissions) + end + end + + @[AC::Route::GET("/current")] + def current : Array(CurrentGroup) + memberships = group_memberships(current_user) + return [] of CurrentGroup if memberships.empty? + + groups = ::PlaceOS::Model::Group.where(id: memberships.keys).to_a + groups.compact_map do |g| + gid = g.id + next if gid.nil? + perms = memberships[gid]? || ::PlaceOS::Model::Permissions::None + next if perms == ::PlaceOS::Model::Permissions::None + CurrentGroup.new(g, perms.to_i) + end + end + end +end + +require "./groups/*" diff --git a/src/placeos-rest-api/controllers/groups/application.cr b/src/placeos-rest-api/controllers/groups/application.cr new file mode 100644 index 00000000..95d295f9 --- /dev/null +++ b/src/placeos-rest-api/controllers/groups/application.cr @@ -0,0 +1,152 @@ +require "placeos-models/group/application" +require "placeos-models/permissions" + +require "../application" + +module PlaceOS::Api + class Groups::Applications < Application + base "/api/engine/v2/group_applications/" + + # Scopes + ############################################################################################### + + before_action :can_read, only: [:index, :show, :effective_permissions, :accessible_zones] + before_action :can_write, only: [:create, :update, :destroy] + + # Only sys_admin can create, update, or destroy. + before_action :check_admin, only: [:create, :update, :destroy] + + ############################################################################################### + + @[AC::Route::Filter(:before_action, except: [:index, :create])] + def find_current_application(id : String) + Log.context.set(group_application_id: id) + @current_application = ::PlaceOS::Model::GroupApplication.find!(UUID.new(id)) + end + + getter! current_application : ::PlaceOS::Model::GroupApplication + + @[AC::Route::Filter(:before_action, only: [:update, :create], body: :application_update)] + def parse_update_application(@application_update : ::PlaceOS::Model::GroupApplication) + end + + getter! application_update : ::PlaceOS::Model::GroupApplication + + ############################################################################################### + + # List applications for the current authority. Any authenticated + # user may list — codes are labels, not secrets. Pass `q` for a + # case-insensitive substring search on `name`. + @[AC::Route::GET("/")] + def index( + @[AC::Param::Info(description: "case-insensitive substring search on name (SQL ILIKE)")] + q : String? = nil, + limit : Int32 = 100, + offset : Int32 = 0, + ) : Array(::PlaceOS::Model::GroupApplication) + authority_id = current_authority.as(::PlaceOS::Model::Authority).id.as(String) + query = ::PlaceOS::Model::GroupApplication.where(authority_id: authority_id) + + if (term = q) && !term.empty? + query = query.where("name ILIKE ?", "%#{term}%") + end + + paginate_sql(query, type: "group_applications", limit: limit, offset: offset) + end + + @[AC::Route::GET("/:id")] + def show : ::PlaceOS::Model::GroupApplication + current_application + end + + @[AC::Route::POST("/", status_code: HTTP::Status::CREATED)] + def create : ::PlaceOS::Model::GroupApplication + app = application_update + app.authority_id = current_authority.as(::PlaceOS::Model::Authority).id.as(String) + app.acting_user = current_user + raise Error::ModelValidation.new(app.errors) unless app.save + app + end + + @[AC::Route::PATCH("/:id")] + @[AC::Route::PUT("/:id")] + def update : ::PlaceOS::Model::GroupApplication + current = current_application + current.assign_attributes(application_update) + current.acting_user = current_user + raise Error::ModelValidation.new(current.errors) unless current.save + current + end + + @[AC::Route::DELETE("/:id", status_code: HTTP::Status::ACCEPTED)] + def destroy : Nil + app = current_application + app.acting_user = current_user + app.destroy + end + + # Permission query helpers + ############################################################################################### + + struct EffectivePermissionsResponse + include JSON::Serializable + + getter application_id : UUID + getter user_id : String + getter permissions : Int32 + getter zone_ids : Array(String) + + def initialize(@application_id, @user_id, @permissions, @zone_ids) + end + end + + # Effective permissions for the *current user* on the given zone(s) + # within this application. Either `zone_id=...` (single) or + # `zone_ids=a,b,c` (comma-separated batch). + @[AC::Route::GET("/:id/effective_permissions", converters: {zone_ids: ConvertStringArray})] + def effective_permissions( + @[AC::Param::Info(description: "single zone id (use either this or zone_ids)")] + zone_id : String? = nil, + @[AC::Param::Info(description: "comma-separated list of zone ids")] + zone_ids : Array(String)? = nil, + ) : EffectivePermissionsResponse + user_id = current_user.id.as(String) + app = current_application + app_id = app.id.as(UUID) + + if zone_ids && !zone_ids.empty? + perms = app.effective_permissions(user_id, zone_ids) + EffectivePermissionsResponse.new(app_id, user_id, perms.to_i, zone_ids) + elsif zone_id + perms = app.effective_permissions(user_id, zone_id) + EffectivePermissionsResponse.new(app_id, user_id, perms.to_i, [zone_id]) + else + raise AC::Route::Param::MissingError.new("either `zone_id` or `zone_ids` must be supplied", "zone_id", "String") + end + end + + struct AccessibleZonesResponse + include JSON::Serializable + + getter application_id : UUID + getter user_id : String + getter zone_ids : Array(String) + + def initialize(@application_id, @user_id, @zone_ids) + end + end + + # Every zone id the current user has any non-zero permission on + # within this application (direct + transitive, minus denies). + @[AC::Route::GET("/:id/accessible_zones")] + def accessible_zones : AccessibleZonesResponse + user_id = current_user.id.as(String) + app = current_application + AccessibleZonesResponse.new( + app.id.as(UUID), + user_id, + app.accessible_zone_ids(user_id), + ) + end + end +end diff --git a/src/placeos-rest-api/controllers/groups/application_doorkeeper.cr b/src/placeos-rest-api/controllers/groups/application_doorkeeper.cr new file mode 100644 index 00000000..810a5b81 --- /dev/null +++ b/src/placeos-rest-api/controllers/groups/application_doorkeeper.cr @@ -0,0 +1,82 @@ +require "placeos-models/group/doorkeeper" + +require "../application" + +module PlaceOS::Api + class Groups::ApplicationDoorkeepers < Application + base "/api/engine/v2/group_application_doorkeepers/" + + # Scopes + ############################################################################################### + + before_action :can_read, only: [:index, :show] + before_action :can_write, only: [:create, :destroy] + + # OAuth client wiring is sys_admin-only across the board. + before_action :check_admin + + ############################################################################################### + + @[AC::Route::Filter(:before_action, except: [:index, :create])] + def find_current_link(group_application_id : String, doorkeeper_application_id : Int64) + Log.context.set( + group_application_id: group_application_id, + doorkeeper_application_id: doorkeeper_application_id, + ) + @current_link = ::PlaceOS::Model::GroupApplicationDoorkeeper.find!({ + UUID.new(group_application_id), + doorkeeper_application_id, + }) + end + + getter! current_link : ::PlaceOS::Model::GroupApplicationDoorkeeper + + @[AC::Route::Filter(:before_action, only: [:create], body: :link_update)] + def parse_link(@link_update : ::PlaceOS::Model::GroupApplicationDoorkeeper) + end + + getter! link_update : ::PlaceOS::Model::GroupApplicationDoorkeeper + + ############################################################################################### + + @[AC::Route::GET("/")] + def index( + group_application_id : String? = nil, + doorkeeper_application_id : Int64? = nil, + limit : Int32 = 100, + offset : Int32 = 0, + ) : Array(::PlaceOS::Model::GroupApplicationDoorkeeper) + authority_id = current_authority.as(::PlaceOS::Model::Authority).id.as(String) + app_ids = ::PlaceOS::Model::GroupApplication + .where(authority_id: authority_id) + .to_a + .map { |a| a.id.as(UUID) } + return [] of ::PlaceOS::Model::GroupApplicationDoorkeeper if app_ids.empty? + + query = ::PlaceOS::Model::GroupApplicationDoorkeeper.where(group_application_id: app_ids) + query = query.where(group_application_id: UUID.new(group_application_id)) if group_application_id + query = query.where(doorkeeper_application_id: doorkeeper_application_id) if doorkeeper_application_id + paginate_sql(query, type: "group_application_doorkeepers", limit: limit, offset: offset) + end + + @[AC::Route::GET("/:group_application_id/:doorkeeper_application_id")] + def show : ::PlaceOS::Model::GroupApplicationDoorkeeper + current_link + end + + @[AC::Route::POST("/", status_code: HTTP::Status::CREATED)] + def create : ::PlaceOS::Model::GroupApplicationDoorkeeper + link = link_update + link.acting_user = current_user + raise Error::ModelValidation.new(link.errors) unless link.save + link + end + + @[AC::Route::DELETE("/:group_application_id/:doorkeeper_application_id", status_code: HTTP::Status::ACCEPTED)] + def destroy : Nil + link = current_link + link.acting_user = current_user + link.destroy + end + end +end diff --git a/src/placeos-rest-api/controllers/groups/application_membership.cr b/src/placeos-rest-api/controllers/groups/application_membership.cr new file mode 100644 index 00000000..b4e03205 --- /dev/null +++ b/src/placeos-rest-api/controllers/groups/application_membership.cr @@ -0,0 +1,84 @@ +require "placeos-models/group/application_membership" + +require "../application" + +module PlaceOS::Api + class Groups::ApplicationMemberships < Application + base "/api/engine/v2/group_application_memberships/" + + # Scopes + ############################################################################################### + + before_action :can_read, only: [:index, :show] + before_action :can_write, only: [:create, :destroy] + + # sys_admin only for create / destroy. Index / show are open to any + # authenticated user — these rows describe which teams are in which + # subsystems, not secrets. + before_action :check_admin, only: [:create, :destroy] + + ############################################################################################### + + # Composite PK: a URL path of `:group_id/:application_id`. + @[AC::Route::Filter(:before_action, except: [:index, :create])] + def find_current_membership(group_id : String, application_id : String) + Log.context.set(group_id: group_id, application_id: application_id) + @current_membership = ::PlaceOS::Model::GroupApplicationMembership.find!({ + UUID.new(group_id), + UUID.new(application_id), + }) + end + + getter! current_membership : ::PlaceOS::Model::GroupApplicationMembership + + @[AC::Route::Filter(:before_action, only: [:create], body: :membership_update)] + def parse_membership(@membership_update : ::PlaceOS::Model::GroupApplicationMembership) + end + + getter! membership_update : ::PlaceOS::Model::GroupApplicationMembership + + ############################################################################################### + + # Memberships for the current authority — scoped via the group. + # Optionally filter by `group_id` or `application_id` query params. + @[AC::Route::GET("/")] + def index( + group_id : String? = nil, + application_id : String? = nil, + limit : Int32 = 100, + offset : Int32 = 0, + ) : Array(::PlaceOS::Model::GroupApplicationMembership) + authority_id = current_authority.as(::PlaceOS::Model::Authority).id.as(String) + app_ids = ::PlaceOS::Model::GroupApplication + .where(authority_id: authority_id) + .to_a + .map { |a| a.id.as(UUID) } + return [] of ::PlaceOS::Model::GroupApplicationMembership if app_ids.empty? + + query = ::PlaceOS::Model::GroupApplicationMembership.where(application_id: app_ids) + query = query.where(group_id: UUID.new(group_id)) if group_id + query = query.where(application_id: UUID.new(application_id)) if application_id + paginate_sql(query, type: "group_application_memberships", limit: limit, offset: offset) + end + + @[AC::Route::GET("/:group_id/:application_id")] + def show : ::PlaceOS::Model::GroupApplicationMembership + current_membership + end + + @[AC::Route::POST("/", status_code: HTTP::Status::CREATED)] + def create : ::PlaceOS::Model::GroupApplicationMembership + membership = membership_update + membership.acting_user = current_user + raise Error::ModelValidation.new(membership.errors) unless membership.save + membership + end + + @[AC::Route::DELETE("/:group_id/:application_id", status_code: HTTP::Status::ACCEPTED)] + def destroy : Nil + membership = current_membership + membership.acting_user = current_user + membership.destroy + end + end +end diff --git a/src/placeos-rest-api/controllers/groups/history.cr b/src/placeos-rest-api/controllers/groups/history.cr new file mode 100644 index 00000000..498060ea --- /dev/null +++ b/src/placeos-rest-api/controllers/groups/history.cr @@ -0,0 +1,74 @@ +require "placeos-models/group" +require "placeos-models/group/history" + +require "../application" + +module PlaceOS::Api + class Groups::History < Application + include Utils::GroupPermissions + + base "/api/engine/v2/group_history/" + + # Scopes + ############################################################################################### + + before_action :can_read, only: [:index, :show] + + ############################################################################################### + + @[AC::Route::Filter(:before_action, only: [:show])] + def find_current_history(id : String) + Log.context.set(group_history_id: id) + @current_history = ::PlaceOS::Model::GroupHistory.find!(UUID.new(id)) + end + + getter! current_history : ::PlaceOS::Model::GroupHistory + + @[AC::Route::Filter(:before_action, only: [:show])] + def check_show_permissions + return if user_admin? + if (gid = current_history.group_id) + group = ::PlaceOS::Model::Group.find!(gid) + ensure_manage!(current_user, group) + else + # History entry without a group_id (e.g. GroupApplication create) + # — sys_admin only. + raise Error::Forbidden.new + end + end + + ############################################################################################### + + # List audit entries. sys_admin sees everything; other callers must + # pass `group_id=` and have Manage on it. `application_id` is an + # optional additional filter for sys_admin callers. + @[AC::Route::GET("/")] + def index( + group_id : String? = nil, + application_id : String? = nil, + limit : Int32 = 100, + offset : Int32 = 0, + ) : Array(::PlaceOS::Model::GroupHistory) + if user_admin? + query = ::PlaceOS::Model::GroupHistory.all + query = query.where(group_id: UUID.new(group_id)) if group_id + query = query.where(application_id: UUID.new(application_id)) if application_id + else + # Non-admin must scope to a specific group they manage. + raise Error::Forbidden.new("group_id is required") unless group_id + target = UUID.new(group_id) + group = ::PlaceOS::Model::Group.find!(target) + ensure_manage!(current_user, group) + query = ::PlaceOS::Model::GroupHistory.where(group_id: target) + query = query.where(application_id: UUID.new(application_id)) if application_id + end + + paginate_sql(query, type: "group_history", limit: limit, offset: offset) + end + + @[AC::Route::GET("/:id")] + def show : ::PlaceOS::Model::GroupHistory + current_history + end + end +end diff --git a/src/placeos-rest-api/controllers/groups/invitation.cr b/src/placeos-rest-api/controllers/groups/invitation.cr new file mode 100644 index 00000000..c5f120fe --- /dev/null +++ b/src/placeos-rest-api/controllers/groups/invitation.cr @@ -0,0 +1,146 @@ +require "uuid" +require "uuid/json" + +require "placeos-models/group" +require "placeos-models/group/invitation" + +require "../application" + +module PlaceOS::Api + class Groups::Invitations < Application + include Utils::GroupPermissions + + base "/api/engine/v2/group_invitations/" + + # Scopes + ############################################################################################### + + before_action :can_read, only: [:index, :show] + before_action :can_write, only: [:create, :destroy, :accept] + + ############################################################################################### + + @[AC::Route::Filter(:before_action, except: [:index, :create])] + def find_current_invitation(id : String) + Log.context.set(invitation_id: id) + @current_invitation = ::PlaceOS::Model::GroupInvitation.find!(UUID.new(id)) + end + + getter! current_invitation : ::PlaceOS::Model::GroupInvitation + + # Permission gates + ############################################################################################### + + @[AC::Route::Filter(:before_action, only: [:show, :destroy])] + def check_modify_permissions + return if user_admin? + group = ::PlaceOS::Model::Group.find!(current_invitation.group_id) + ensure_manage!(current_user, group) + end + # create's permission check is done inline in the action, since the + # incoming body is typed as `InvitationCreatePayload` (not the model) + # and can't easily be consumed in a before_action without colliding + # with the action's body binding. + + ############################################################################################### + + # Create payload — the plaintext secret is generated server-side and + # returned *once* in the response's `plaintext_secret` field. It is + # not persisted; callers must capture it immediately. + struct InvitationCreatePayload + include JSON::Serializable + + getter group_id : String + getter email : String + getter permissions : Int32 = 0 + getter expires_at : Time? = nil + end + + # Response struct that exposes the plaintext secret on creation. + struct InvitationCreatedResponse + include JSON::Serializable + + getter invitation : ::PlaceOS::Model::GroupInvitation + getter plaintext_secret : String + + def initialize(@invitation, @plaintext_secret) + end + end + + # List invitations. Optionally filter by `group_id`. Non-admin + # callers must have Manage on the target group. + @[AC::Route::GET("/")] + def index( + group_id : String? = nil, + limit : Int32 = 100, + offset : Int32 = 0, + ) : Array(::PlaceOS::Model::GroupInvitation) + authority_id = current_authority.as(::PlaceOS::Model::Authority).id.as(String) + authority_group_ids = ::PlaceOS::Model::Group + .where(authority_id: authority_id) + .to_a + .map { |g| g.id.as(UUID) } + return [] of ::PlaceOS::Model::GroupInvitation if authority_group_ids.empty? + + query = ::PlaceOS::Model::GroupInvitation.where(group_id: authority_group_ids) + + if group_id + target = UUID.new(group_id) + unless user_admin? + group = ::PlaceOS::Model::Group.find!(target) + ensure_manage!(current_user, group) + end + query = query.where(group_id: target) + else + unless user_admin? + managed = manageable_group_ids(current_user) + return [] of ::PlaceOS::Model::GroupInvitation if managed.empty? + query = query.where(group_id: managed) + end + end + + paginate_sql(query, type: "group_invitations", limit: limit, offset: offset) + end + + @[AC::Route::GET("/:id")] + def show : ::PlaceOS::Model::GroupInvitation + current_invitation + end + + # Custom body-driven create so we can return the plaintext secret + # alongside the invitation. Authorisation check is inline here (not + # a before_action) so the body is consumed exactly once. + @[AC::Route::POST("/", body: :payload, status_code: HTTP::Status::CREATED)] + def create(payload : InvitationCreatePayload) : InvitationCreatedResponse + group = ::PlaceOS::Model::Group.find!(UUID.new(payload.group_id)) + ensure_manage!(current_user, group) unless user_admin? + + perms = ::PlaceOS::Model::Permissions.new(payload.permissions) + invitation = ::PlaceOS::Model::GroupInvitation.build_with_secret( + group: group, + email: payload.email, + permissions: perms, + expires_at: payload.expires_at, + ) + invitation.acting_user = current_user + raise Error::ModelValidation.new(invitation.errors) unless invitation.save + InvitationCreatedResponse.new(invitation, invitation.plaintext_secret.as(String)) + end + + @[AC::Route::DELETE("/:id", status_code: HTTP::Status::ACCEPTED)] + def destroy : Nil + invitation = current_invitation + invitation.acting_user = current_user + invitation.destroy + end + + # Accept the invitation, materialising it into a GroupUser for the + # current user. The invitation row is destroyed on success. + @[AC::Route::POST("/:id/accept")] + def accept : ::PlaceOS::Model::GroupUser + invitation = current_invitation + raise Error::Forbidden.new("invitation expired") if invitation.expired? + invitation.accept!(current_user) + end + end +end diff --git a/src/placeos-rest-api/controllers/groups/user.cr b/src/placeos-rest-api/controllers/groups/user.cr new file mode 100644 index 00000000..d3c9761d --- /dev/null +++ b/src/placeos-rest-api/controllers/groups/user.cr @@ -0,0 +1,151 @@ +require "placeos-models/group" +require "placeos-models/group/user" + +require "../application" + +module PlaceOS::Api + class Groups::Users < Application + include Utils::GroupPermissions + + base "/api/engine/v2/group_users/" + + # Scopes + ############################################################################################### + + before_action :can_read, only: [:index, :show] + before_action :can_write, only: [:create, :update, :destroy] + + ############################################################################################### + + @[AC::Route::Filter(:before_action, except: [:index, :create])] + def find_current_group_user(user_id : String, group_id : String) + Log.context.set(user_id: user_id, group_id: group_id) + @current_group_user = ::PlaceOS::Model::GroupUser.find!({user_id, UUID.new(group_id)}) + end + + getter! current_group_user : ::PlaceOS::Model::GroupUser + + @[AC::Route::Filter(:before_action, only: [:create, :update], body: :group_user_update)] + def parse_group_user(@group_user_update : ::PlaceOS::Model::GroupUser) + end + + getter! group_user_update : ::PlaceOS::Model::GroupUser + + # Permission gates + ############################################################################################### + + @[AC::Route::Filter(:before_action, only: [:show])] + def check_show_permissions + return if user_admin? + gu = current_group_user + # Own entry always visible. + return if gu.user_id == current_user.id + group = ::PlaceOS::Model::Group.find!(gu.group_id) + ensure_manage!(current_user, group) + end + + @[AC::Route::Filter(:before_action, only: [:create])] + def check_create_permissions + return if user_admin? + group = ::PlaceOS::Model::Group.find!(group_user_update.group_id) + ensure_manage!(current_user, group) + end + + @[AC::Route::Filter(:before_action, only: [:update, :destroy])] + def check_modify_permissions + return if user_admin? + group = ::PlaceOS::Model::Group.find!(current_group_user.group_id) + ensure_manage!(current_user, group) + end + + ############################################################################################### + + # GroupUser rows. Optional `group_id` scopes to one group; + # `user_id=me` returns the current user's own rows. Non-admin + # callers without Manage on the specified group see only their own. + @[AC::Route::GET("/")] + def index( + group_id : String? = nil, + user_id : String? = nil, + limit : Int32 = 100, + offset : Int32 = 0, + ) : Array(::PlaceOS::Model::GroupUser) + # Constrain to the caller's authority. + authority_id = current_authority.as(::PlaceOS::Model::Authority).id.as(String) + authority_group_ids = ::PlaceOS::Model::Group + .where(authority_id: authority_id) + .to_a + .map { |g| g.id.as(UUID) } + return [] of ::PlaceOS::Model::GroupUser if authority_group_ids.empty? + + query = ::PlaceOS::Model::GroupUser.where(group_id: authority_group_ids) + + if user_id == "me" + query = query.where(user_id: current_user.id.as(String)) + elsif user_id + # Only sys_admin can look up other users. + raise Error::Forbidden.new unless user_admin? || user_id == current_user.id + query = query.where(user_id: user_id) + end + + if group_id + target = UUID.new(group_id) + unless user_admin? + group = ::PlaceOS::Model::Group.find!(target) + # Non-Manager: allow only if asking about their own entries. + unless user_has_manage?(current_user, group) + raise Error::Forbidden.new unless user_id == "me" || user_id == current_user.id + end + end + query = query.where(group_id: target) + elsif !user_admin? + # No group filter + non-admin: restrict to groups they manage OR their own rows. + managed = manageable_group_ids(current_user) + own_user_id = current_user.id.as(String) + if managed.empty? + query = query.where(user_id: own_user_id) + else + # (group_id IN managed) OR user_id = self + ids_list = managed.map(&.to_s).join(",") { |s| "'#{s}'" } + query = query.where( + "(group_id::text = ANY(ARRAY[#{ids_list}]) OR user_id = ?)", own_user_id, + ) + end + end + + paginate_sql(query, type: "group_users", limit: limit, offset: offset) + end + + @[AC::Route::GET("/:user_id/:group_id")] + def show : ::PlaceOS::Model::GroupUser + current_group_user + end + + @[AC::Route::POST("/", status_code: HTTP::Status::CREATED)] + def create : ::PlaceOS::Model::GroupUser + gu = group_user_update + gu.acting_user = current_user + raise Error::ModelValidation.new(gu.errors) unless gu.save + gu + end + + @[AC::Route::PATCH("/:user_id/:group_id")] + @[AC::Route::PUT("/:user_id/:group_id")] + def update : ::PlaceOS::Model::GroupUser + current = current_group_user + # Only permissions are mutable on this junction; composite PK is immutable. + new_perms = group_user_update.permissions + current.permissions = new_perms + current.acting_user = current_user + raise Error::ModelValidation.new(current.errors) unless current.save + current + end + + @[AC::Route::DELETE("/:user_id/:group_id", status_code: HTTP::Status::ACCEPTED)] + def destroy : Nil + gu = current_group_user + gu.acting_user = current_user + gu.destroy + end + end +end diff --git a/src/placeos-rest-api/controllers/groups/zone.cr b/src/placeos-rest-api/controllers/groups/zone.cr new file mode 100644 index 00000000..72d4771e --- /dev/null +++ b/src/placeos-rest-api/controllers/groups/zone.cr @@ -0,0 +1,131 @@ +require "placeos-models/group" +require "placeos-models/group/zone" + +require "../application" + +module PlaceOS::Api + class Groups::Zones < Application + include Utils::GroupPermissions + + base "/api/engine/v2/group_zones/" + + # Scopes + ############################################################################################### + + before_action :can_read, only: [:index, :show] + before_action :can_write, only: [:create, :update, :destroy] + + ############################################################################################### + + @[AC::Route::Filter(:before_action, except: [:index, :create])] + def find_current_group_zone(group_id : String, zone_id : String) + Log.context.set(group_id: group_id, zone_id: zone_id) + @current_group_zone = ::PlaceOS::Model::GroupZone.find!({UUID.new(group_id), zone_id}) + end + + getter! current_group_zone : ::PlaceOS::Model::GroupZone + + @[AC::Route::Filter(:before_action, only: [:create, :update], body: :group_zone_update)] + def parse_group_zone(@group_zone_update : ::PlaceOS::Model::GroupZone) + end + + getter! group_zone_update : ::PlaceOS::Model::GroupZone + + # Permission gates + ############################################################################################### + + @[AC::Route::Filter(:before_action, only: [:show])] + def check_show_permissions + return if user_admin? + group = ::PlaceOS::Model::Group.find!(current_group_zone.group_id) + ensure_member!(current_user, group) + end + + @[AC::Route::Filter(:before_action, only: [:create])] + def check_create_permissions + return if user_admin? + group = ::PlaceOS::Model::Group.find!(group_zone_update.group_id) + ensure_manage!(current_user, group) + # "Can only delegate what you have": the zone being granted must + # already be reachable from one of the caller's manageable groups. + ensure_zone_delegatable!(current_user, group_zone_update.zone_id) + end + + @[AC::Route::Filter(:before_action, only: [:update, :destroy])] + def check_modify_permissions + return if user_admin? + group = ::PlaceOS::Model::Group.find!(current_group_zone.group_id) + ensure_manage!(current_user, group) + end + + ############################################################################################### + + # List GroupZone rows. Filterable by group_id / zone_id. + @[AC::Route::GET("/")] + def index( + group_id : String? = nil, + zone_id : String? = nil, + limit : Int32 = 100, + offset : Int32 = 0, + ) : Array(::PlaceOS::Model::GroupZone) + authority_id = current_authority.as(::PlaceOS::Model::Authority).id.as(String) + authority_group_ids = ::PlaceOS::Model::Group + .where(authority_id: authority_id) + .to_a + .map { |g| g.id.as(UUID) } + return [] of ::PlaceOS::Model::GroupZone if authority_group_ids.empty? + + query = ::PlaceOS::Model::GroupZone.where(group_id: authority_group_ids) + + if group_id + target = UUID.new(group_id) + unless user_admin? + group = ::PlaceOS::Model::Group.find!(target) + ensure_member!(current_user, group) + end + query = query.where(group_id: target) + else + unless user_admin? + viewable = viewable_group_ids(current_user) + return [] of ::PlaceOS::Model::GroupZone if viewable.empty? + query = query.where(group_id: viewable) + end + end + + query = query.where(zone_id: zone_id) if zone_id + paginate_sql(query, type: "group_zones", limit: limit, offset: offset) + end + + @[AC::Route::GET("/:group_id/:zone_id")] + def show : ::PlaceOS::Model::GroupZone + current_group_zone + end + + @[AC::Route::POST("/", status_code: HTTP::Status::CREATED)] + def create : ::PlaceOS::Model::GroupZone + gz = group_zone_update + gz.acting_user = current_user + raise Error::ModelValidation.new(gz.errors) unless gz.save + gz + end + + @[AC::Route::PATCH("/:group_id/:zone_id")] + @[AC::Route::PUT("/:group_id/:zone_id")] + def update : ::PlaceOS::Model::GroupZone + current = current_group_zone + update = group_zone_update + current.permissions = update.permissions + current.deny = update.deny + current.acting_user = current_user + raise Error::ModelValidation.new(current.errors) unless current.save + current + end + + @[AC::Route::DELETE("/:group_id/:zone_id", status_code: HTTP::Status::ACCEPTED)] + def destroy : Nil + gz = current_group_zone + gz.acting_user = current_user + gz.destroy + end + end +end diff --git a/src/placeos-rest-api/utilities/group_permissions.cr b/src/placeos-rest-api/utilities/group_permissions.cr new file mode 100644 index 00000000..0fe3dc89 --- /dev/null +++ b/src/placeos-rest-api/utilities/group_permissions.cr @@ -0,0 +1,133 @@ +require "placeos-models/group" +require "placeos-models/group/application" +require "placeos-models/group/user" +require "placeos-models/group/zone" +require "placeos-models/permissions" + +module PlaceOS::Api + # Helpers for authorising actions against the group-permissions system. + # Include in any controller that needs to gate on a user's group + # membership or Manage grant. + # + # All helpers operate on the *effective* membership of a user computed + # via the group tree's replace semantics (closest explicit ancestor + # GroupUser entry wins). A Manage grant on a group implicitly covers + # that group's descendants. + module Utils::GroupPermissions + # Effective per-group Permissions for the user, keyed by group id. + # Groups where the user has no transitive membership are absent. + def group_memberships(user : ::PlaceOS::Model::User) : Hash(UUID, ::PlaceOS::Model::Permissions) + user_id = user.id.as(String) + + # Direct GroupUser rows, scoped to the user's authority. + direct = {} of UUID => ::PlaceOS::Model::Permissions + sql = <<-SQL + SELECT gu.group_id, gu.permissions + FROM group_users gu + INNER JOIN groups g ON g.id = gu.group_id + WHERE gu.user_id = $1 AND g.authority_id = $2 + SQL + args = [user_id.as(::PgORM::Value), user.authority_id.as(::PgORM::Value)] + ::PgORM::Database.connection do |conn| + conn.query_all(sql, args: args) do |rs| + gid = rs.read(UUID) + perms = rs.read(Int32) + direct[gid] = ::PlaceOS::Model::Permissions.new(perms) + end + end + return direct if direct.empty? + + # Walk up the authority's group tree so transitive memberships + # inherit the closest explicit ancestor's perms. + parent_of = {} of UUID => UUID + all_ids = [] of UUID + ::PlaceOS::Model::Group.where(authority_id: user.authority_id).each do |g| + gid = g.id.not_nil! + all_ids << gid + if (parent = g.parent_id) + parent_of[gid] = parent + end + end + + effective = {} of UUID => ::PlaceOS::Model::Permissions + all_ids.each do |gid| + current = gid + loop do + if (p = direct[current]?) + effective[gid] = p + break + end + next_parent = parent_of[current]? + break if next_parent.nil? + current = next_parent + end + end + effective + end + + # True if `user` is a member (any non-zero perm) of `group`, + # directly or via ancestor. + def user_is_member?(user : ::PlaceOS::Model::User, group : ::PlaceOS::Model::Group) : Bool + gid = group.id + return false if gid.nil? + perms = group_memberships(user)[gid]? + !perms.nil? && perms != ::PlaceOS::Model::Permissions::None + end + + # True if `user` has Manage on `group` (directly or via ancestor). + def user_has_manage?(user : ::PlaceOS::Model::User, group : ::PlaceOS::Model::Group) : Bool + gid = group.id + return false if gid.nil? + perms = group_memberships(user)[gid]? + !perms.nil? && perms.manage? + end + + # Raises Error::Forbidden unless the user has any effective + # permission on `group`. + def ensure_member!(user : ::PlaceOS::Model::User, group : ::PlaceOS::Model::Group) + raise Error::Forbidden.new unless user_is_member?(user, group) + end + + # Raises Error::Forbidden unless the user has Manage on `group`. + def ensure_manage!(user : ::PlaceOS::Model::User, group : ::PlaceOS::Model::Group) + raise Error::Forbidden.new unless user_has_manage?(user, group) + end + + # Group ids the user is any kind of member of (direct or transitive). + # Useful for scoping index queries. + def viewable_group_ids(user : ::PlaceOS::Model::User) : Array(UUID) + group_memberships(user).compact_map { |gid, perms| gid if perms != ::PlaceOS::Model::Permissions::None } + end + + # Group ids the user has Manage on (direct or transitive). + def manageable_group_ids(user : ::PlaceOS::Model::User) : Array(UUID) + group_memberships(user).compact_map { |gid, perms| gid if perms.manage? } + end + + # True if `zone_id` is already reachable from at least one group the + # user has Manage on — via any application that group is a member of. + # Enforces the "can only delegate what you have" rule for GroupZone + # creation. + def user_can_delegate_zone?(user : ::PlaceOS::Model::User, zone_id : String) : Bool + managed_ids = manageable_group_ids(user) + return false if managed_ids.empty? + + # Applications those managed groups participate in + app_ids = [] of UUID + ::PlaceOS::Model::GroupApplicationMembership.where(group_id: managed_ids).each do |m| + app_ids << m.application_id unless app_ids.includes?(m.application_id) + end + return false if app_ids.empty? + + user_id = user.id.as(String) + ::PlaceOS::Model::GroupApplication.where(id: app_ids).each do |app| + return true if app.zone_accessible?(user_id, zone_id) + end + false + end + + def ensure_zone_delegatable!(user : ::PlaceOS::Model::User, zone_id : String) + raise Error::Forbidden.new("zone not reachable from any of your manageable groups") unless user_can_delegate_zone?(user, zone_id) + end + end +end