diff --git a/shard.lock b/shard.lock index 6c0eea3c..6c5f5f34 100644 --- a/shard.lock +++ b/shard.lock @@ -163,7 +163,7 @@ shards: office365: git: https://github.com/placeos/office365.git - version: 1.27.1 + version: 1.27.2 openai: git: https://github.com/spider-gazelle/crystal-openai.git @@ -187,7 +187,7 @@ shards: pg-orm: git: https://github.com/spider-gazelle/pg-orm.git - version: 2.2.1 + version: 2.2.2 pinger: git: https://github.com/spider-gazelle/pinger.git @@ -215,7 +215,7 @@ shards: placeos-frontend-loader: git: https://github.com/placeos/frontend-loader.git - version: 2.7.1+git.commit.693b47914d40284458518503be7441dfc7ff98aa + version: 2.7.1+git.commit.4d12eb11b0b381f4d7927b3d7086cbc5b42f21c9 placeos-log-backend: git: https://github.com/place-labs/log-backend.git @@ -223,7 +223,7 @@ shards: placeos-models: git: https://github.com/placeos/models.git - version: 9.98.3 + version: 9.98.4 placeos-resource: git: https://github.com/place-labs/resource.git @@ -275,7 +275,7 @@ shards: search-ingest: git: https://github.com/placeos/search-ingest.git - version: 2.11.3+git.commit.f3e14c701667f3fb185a82b352d4e053c7f082d2 + version: 2.11.3+git.commit.57d54cc34bece9abf2c2704f76358b3cd4a3199a secrets-env: # Overridden git: https://github.com/spider-gazelle/secrets-env.git diff --git a/spec/controllers/asset_categories_spec.cr b/spec/controllers/asset_categories_spec.cr index 7c16303e..c0249ff7 100644 --- a/spec/controllers/asset_categories_spec.cr +++ b/spec/controllers/asset_categories_spec.cr @@ -27,5 +27,129 @@ module PlaceOS::Api describe "scopes" do Spec.test_controller_scope(AssetCategories) end + + describe "support-subsystem permissions" do + ::Spec.before_each { clear_group_tables } + + it "allows POST for a support user with Create on the org zone (both sides)" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Create).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Create).save! + + body = Model::Generator.asset_category.to_json + result = client.post(AssetCategories.base_route, body: body, headers: headers) + result.status_code.should eq 201 + + Model::AssetCategory.from_trusted_json(result.body).destroy + end + + it "rejects POST when the support user only has Read on the org zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Read).save! + + body = Model::Generator.asset_category.to_json + result = client.post(AssetCategories.base_route, body: body, headers: headers) + result.status_code.should eq 403 + end + + it "requires Update on both sides to PATCH an asset category" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + asset_category = Model::Generator.asset_category.save! + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Update).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Update).save! + + result = client.patch( + path: "#{AssetCategories.base_route}#{asset_category.id}", + body: {name: "renamed-#{random_name}"}.to_json, + headers: headers, + ) + result.success?.should be_true + asset_category.destroy + end + + it "rejects PATCH when the support user only has Create on the org zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + asset_category = Model::Generator.asset_category.save! + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Create).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Create).save! + + result = client.patch( + path: "#{AssetCategories.base_route}#{asset_category.id}", + body: {name: "renamed-#{random_name}"}.to_json, + headers: headers, + ) + result.status_code.should eq 403 + asset_category.destroy + end + + it "requires Delete on both sides to DELETE an asset category" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + asset_category = Model::Generator.asset_category.save! + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Delete).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Delete).save! + + result = client.delete(path: "#{AssetCategories.base_route}#{asset_category.id}", headers: headers) + result.success?.should be_true + Model::AssetCategory.find?(asset_category.id).should be_nil + end + + it "rejects DELETE when the support user only has Update on the org zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + asset_category = Model::Generator.asset_category.save! + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Update).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Update).save! + + result = client.delete(path: "#{AssetCategories.base_route}#{asset_category.id}", headers: headers) + result.status_code.should eq 403 + asset_category.destroy + end + + it "allows a support-JWT user to POST regardless of group grants" do + body = Model::Generator.asset_category.to_json + result = client.post( + AssetCategories.base_route, + body: body, + headers: Spec::Authentication.headers(sys_admin: false, support: true), + ) + result.status_code.should eq 201 + Model::AssetCategory.from_trusted_json(result.body).destroy + end + + it "allows an admin-JWT user to POST regardless of group grants" do + body = Model::Generator.asset_category.to_json + result = client.post( + AssetCategories.base_route, + body: body, + headers: Spec::Authentication.headers(sys_admin: true, support: false), + ) + result.status_code.should eq 201 + Model::AssetCategory.from_trusted_json(result.body).destroy + end + end end end diff --git a/spec/controllers/asset_purchase_orders_spec.cr b/spec/controllers/asset_purchase_orders_spec.cr index c26529a3..95838bfe 100644 --- a/spec/controllers/asset_purchase_orders_spec.cr +++ b/spec/controllers/asset_purchase_orders_spec.cr @@ -47,5 +47,157 @@ module PlaceOS::Api describe "scopes" do Spec.test_controller_scope(AssetPurchaseOrders) end + + describe "support-subsystem permissions" do + ::Spec.before_each { clear_group_tables } + + it "allows POST for a support user with Create on the org zone (both sides)" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Create).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Create).save! + + body = Model::Generator.asset_purchase_order.to_json + result = client.post(AssetPurchaseOrders.base_route, body: body, headers: headers) + result.status_code.should eq 201 + + Model::AssetPurchaseOrder.from_trusted_json(result.body).destroy + end + + it "rejects POST when the support user only has Read on the org zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Read).save! + + body = Model::Generator.asset_purchase_order.to_json + result = client.post(AssetPurchaseOrders.base_route, body: body, headers: headers) + result.status_code.should eq 403 + end + + it "requires Update on both sides to PATCH a purchase order" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + purchase_order = Model::Generator.asset_purchase_order.save! + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Update).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Update).save! + + result = client.patch( + path: "#{AssetPurchaseOrders.base_route}#{purchase_order.id}", + body: {purchase_order_number: "po-#{random_name}"}.to_json, + headers: headers, + ) + result.success?.should be_true + purchase_order.destroy + end + + it "rejects PATCH when the support user only has Create on the org zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + purchase_order = Model::Generator.asset_purchase_order.save! + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Create).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Create).save! + + result = client.patch( + path: "#{AssetPurchaseOrders.base_route}#{purchase_order.id}", + body: {purchase_order_number: "po-#{random_name}"}.to_json, + headers: headers, + ) + result.status_code.should eq 403 + purchase_order.destroy + end + + it "requires Delete on both sides to DELETE a purchase order" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + purchase_order = Model::Generator.asset_purchase_order.save! + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Delete).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Delete).save! + + result = client.delete(path: "#{AssetPurchaseOrders.base_route}#{purchase_order.id}", headers: headers) + result.success?.should be_true + Model::AssetPurchaseOrder.find?(purchase_order.id).should be_nil + end + + it "rejects DELETE when the support user only has Update on the org zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + purchase_order = Model::Generator.asset_purchase_order.save! + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Update).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Update).save! + + result = client.delete(path: "#{AssetPurchaseOrders.base_route}#{purchase_order.id}", headers: headers) + result.status_code.should eq 403 + purchase_order.destroy + end + + # index/show map the verb bit to None, so the support path requires + # Manage on the org zone to read. + it "allows GET index for a support user with Manage on the org zone (both sides)" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Manage).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Manage).save! + + result = client.get(AssetPurchaseOrders.base_route, headers: headers) + result.status_code.should eq 200 + end + + it "rejects GET index when the support user only has Read on the org zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Read).save! + + result = client.get(AssetPurchaseOrders.base_route, headers: headers) + result.status_code.should eq 403 + end + + it "allows a support-JWT user to POST regardless of group grants" do + body = Model::Generator.asset_purchase_order.to_json + result = client.post( + AssetPurchaseOrders.base_route, + body: body, + headers: Spec::Authentication.headers(sys_admin: false, support: true), + ) + result.status_code.should eq 201 + Model::AssetPurchaseOrder.from_trusted_json(result.body).destroy + end + + it "allows an admin-JWT user to POST regardless of group grants" do + body = Model::Generator.asset_purchase_order.to_json + result = client.post( + AssetPurchaseOrders.base_route, + body: body, + headers: Spec::Authentication.headers(sys_admin: true, support: false), + ) + result.status_code.should eq 201 + Model::AssetPurchaseOrder.from_trusted_json(result.body).destroy + end + end end end diff --git a/spec/controllers/asset_types_spec.cr b/spec/controllers/asset_types_spec.cr index b722ca31..c44b6f56 100644 --- a/spec/controllers/asset_types_spec.cr +++ b/spec/controllers/asset_types_spec.cr @@ -89,5 +89,129 @@ module PlaceOS::Api describe "scopes" do Spec.test_controller_scope(AssetTypes) end + + describe "support-subsystem permissions" do + ::Spec.before_each { clear_group_tables } + + it "allows POST for a support user with Create on the org zone (both sides)" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Create).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Create).save! + + body = Model::Generator.asset_type.to_json + result = client.post(AssetTypes.base_route, body: body, headers: headers) + result.status_code.should eq 201 + + Model::AssetType.from_trusted_json(result.body).destroy + end + + it "rejects POST when the support user only has Read on the org zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Read).save! + + body = Model::Generator.asset_type.to_json + result = client.post(AssetTypes.base_route, body: body, headers: headers) + result.status_code.should eq 403 + end + + it "requires Update on both sides to PATCH an asset type" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + asset_type = Model::Generator.asset_type.save! + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Update).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Update).save! + + result = client.patch( + path: "#{AssetTypes.base_route}#{asset_type.id}", + body: {name: "renamed-#{random_name}"}.to_json, + headers: headers, + ) + result.success?.should be_true + asset_type.destroy + end + + it "rejects PATCH when the support user only has Create on the org zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + asset_type = Model::Generator.asset_type.save! + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Create).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Create).save! + + result = client.patch( + path: "#{AssetTypes.base_route}#{asset_type.id}", + body: {name: "renamed-#{random_name}"}.to_json, + headers: headers, + ) + result.status_code.should eq 403 + asset_type.destroy + end + + it "requires Delete on both sides to DELETE an asset type" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + asset_type = Model::Generator.asset_type.save! + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Delete).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Delete).save! + + result = client.delete(path: "#{AssetTypes.base_route}#{asset_type.id}", headers: headers) + result.success?.should be_true + Model::AssetType.find?(asset_type.id).should be_nil + end + + it "rejects DELETE when the support user only has Update on the org zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + asset_type = Model::Generator.asset_type.save! + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Update).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Update).save! + + result = client.delete(path: "#{AssetTypes.base_route}#{asset_type.id}", headers: headers) + result.status_code.should eq 403 + asset_type.destroy + end + + it "allows a support-JWT user to POST regardless of group grants" do + body = Model::Generator.asset_type.to_json + result = client.post( + AssetTypes.base_route, + body: body, + headers: Spec::Authentication.headers(sys_admin: false, support: true), + ) + result.status_code.should eq 201 + Model::AssetType.from_trusted_json(result.body).destroy + end + + it "allows an admin-JWT user to POST regardless of group grants" do + body = Model::Generator.asset_type.to_json + result = client.post( + AssetTypes.base_route, + body: body, + headers: Spec::Authentication.headers(sys_admin: true, support: false), + ) + result.status_code.should eq 201 + Model::AssetType.from_trusted_json(result.body).destroy + end + end end end diff --git a/spec/controllers/assets_spec.cr b/spec/controllers/assets_spec.cr index 6f252cd2..53c07ef2 100644 --- a/spec/controllers/assets_spec.cr +++ b/spec/controllers/assets_spec.cr @@ -86,5 +86,228 @@ module PlaceOS::Api describe "scopes" do Spec.test_controller_scope(Assets) end + + describe "support-subsystem permissions" do + ::Spec.before_each { clear_group_tables } + + # Build an Asset (unsaved) whose `zone_id` is the supplied zone. + build_asset = ->(zone : Model::Zone) { + asset_type = Model::Generator.asset_type.save! + purchase_order = Model::Generator.asset_purchase_order.save! + asset = Model::Asset.new( + asset_type_id: asset_type.id, + purchase_order_id: purchase_order.id, + zone_id: zone.id, + ) + asset + } + + it "allows POST for a support user with Create on both sides of the asset's zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Create).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Create).save! + + asset = build_asset.call(zone) + result = client.post(Assets.base_route, body: asset.to_json, headers: headers) + result.status_code.should eq 201 + + created = Model::Asset.from_trusted_json(result.body) + created.destroy + zone.destroy + end + + it "rejects POST when the support user only has Read on the asset's zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Read).save! + + asset = build_asset.call(zone) + result = client.post(Assets.base_route, body: asset.to_json, headers: headers) + result.status_code.should eq 403 + zone.destroy + end + + it "requires Update on both sides to PATCH an asset" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + asset = build_asset.call(zone).save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Update).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Update).save! + + result = client.patch( + path: "#{Assets.base_route}#{asset.id}", + body: {name: "renamed-#{random_name}"}.to_json, + headers: headers, + ) + result.success?.should be_true + + asset.destroy + zone.destroy + end + + it "rejects PATCH when the support user only has Create on the asset's zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + asset = build_asset.call(zone).save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Create).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Create).save! + + result = client.patch( + path: "#{Assets.base_route}#{asset.id}", + body: {name: "renamed-#{random_name}"}.to_json, + headers: headers, + ) + result.status_code.should eq 403 + + asset.destroy + zone.destroy + end + + it "requires Delete on both sides to DELETE an asset" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + asset = build_asset.call(zone).save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Delete).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Delete).save! + + result = client.delete(path: "#{Assets.base_route}#{asset.id}", headers: headers) + result.success?.should be_true + Model::Asset.find?(asset.id).should be_nil + + zone.destroy + end + + it "rejects DELETE when the support user only has Update on the asset's zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + asset = build_asset.call(zone).save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Update).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Update).save! + + result = client.delete(path: "#{Assets.base_route}#{asset.id}", headers: headers) + result.status_code.should eq 403 + + asset.destroy + zone.destroy + end + + it "rejects POST when the support grant is on a different zone than the asset's zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + asset_zone = Model::Generator.zone.save! + other_zone = Model::Generator.zone.save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Create).save! + # grant is on `other_zone`, but the asset lives in `asset_zone` + Model::Generator.group_zone(group: group, zone: other_zone, permissions: Model::Permissions::Create).save! + + asset = build_asset.call(asset_zone) + result = client.post(Assets.base_route, body: asset.to_json, headers: headers) + result.status_code.should eq 403 + + asset_zone.destroy + other_zone.destroy + end + + it "gates POST /assets/bulk: rejects when user lacks a grant on the asset's zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + asset_zone = Model::Generator.zone.save! + other_zone = Model::Generator.zone.save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Create).save! + Model::Generator.group_zone(group: group, zone: other_zone, permissions: Model::Permissions::Create).save! + + asset = build_asset.call(asset_zone) + result = client.post( + "#{Assets.base_route}bulk", + body: [asset].to_json, + headers: headers, + ) + result.status_code.should eq 403 + + asset_zone.destroy + other_zone.destroy + end + + it "gates POST /assets/bulk: succeeds with a proper Create grant on the asset's zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Create).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Create).save! + + asset = build_asset.call(zone) + result = client.post( + "#{Assets.base_route}bulk", + body: [asset].to_json, + headers: headers, + ) + result.status_code.should eq 201 + + Array(Model::Asset).from_json(result.body).each(&.destroy) + zone.destroy + end + + it "allows a support-JWT user to POST regardless of group grants" do + zone = Model::Generator.zone.save! + asset = build_asset.call(zone) + result = client.post( + Assets.base_route, + body: asset.to_json, + headers: Spec::Authentication.headers(sys_admin: false, support: true), + ) + result.status_code.should eq 201 + + created = Model::Asset.from_trusted_json(result.body) + created.destroy + zone.destroy + end + + it "allows an admin-JWT user to POST regardless of group grants" do + zone = Model::Generator.zone.save! + asset = build_asset.call(zone) + result = client.post( + Assets.base_route, + body: asset.to_json, + headers: Spec::Authentication.headers(sys_admin: true, support: false), + ) + result.status_code.should eq 201 + + created = Model::Asset.from_trusted_json(result.body) + created.destroy + zone.destroy + end + end end end diff --git a/spec/controllers/metadata_spec.cr b/spec/controllers/metadata_spec.cr index c0ada5fe..138ced7e 100644 --- a/spec/controllers/metadata_spec.cr +++ b/spec/controllers/metadata_spec.cr @@ -477,6 +477,209 @@ module PlaceOS::Api end end + describe "support subsystem permissions" do + ::Spec.before_each { clear_group_tables } + + # ---------------------------------------------------------------- + # 1. zone parent: PATCH needs Update on both sides + # ---------------------------------------------------------------- + + it "allows PATCH metadata on a zone parent with support Update on both sides" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + zone_id = zone.id.as(String) + Model::Generator.metadata(name: "support-meta", parent: zone_id).save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Update).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Update).save! + + meta = Model::Metadata::Interface.new( + name: "support-meta", + description: "", + details: JSON.parse(%({"hello":"world"})), + parent_id: nil, + editors: Set(String).new, + ) + + result = client.patch( + path: "#{Metadata.base_route}/#{zone_id}", + body: meta.to_json, + headers: headers, + ) + result.status_code.should eq 200 + + zone.destroy + end + + it "rejects PATCH metadata on a zone parent when the user only has support Read" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + zone_id = zone.id.as(String) + Model::Generator.metadata(name: "support-meta", parent: zone_id).save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Read).save! + + meta = Model::Metadata::Interface.new( + name: "support-meta", + description: "", + details: JSON.parse(%({"hello":"world"})), + parent_id: nil, + editors: Set(String).new, + ) + + result = client.patch( + path: "#{Metadata.base_route}/#{zone_id}", + body: meta.to_json, + headers: headers, + ) + result.status_code.should eq 403 + + zone.destroy + end + + # ---------------------------------------------------------------- + # 2. NEW sys- parent branch: PATCH via the system's zone + # ---------------------------------------------------------------- + + it "allows PATCH metadata on a sys- parent via the system's zone with support Update" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + sys = Model::Generator.control_system + sys.zones = [zone.id.as(String)] + sys.save! + sys_id = sys.id.as(String) + Model::Generator.metadata(name: "support-meta", parent: sys_id).save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Update).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Update).save! + + meta = Model::Metadata::Interface.new( + name: "support-meta", + description: "", + details: JSON.parse(%({"hello":"world"})), + parent_id: nil, + editors: Set(String).new, + ) + + result = client.patch( + path: "#{Metadata.base_route}/#{sys_id}", + body: meta.to_json, + headers: headers, + ) + result.status_code.should eq 200 + + sys.destroy + zone.destroy + end + + # ---------------------------------------------------------------- + # 3. destroy on a zone parent needs Delete on both sides + # ---------------------------------------------------------------- + + it "allows DELETE metadata on a zone parent with support Delete on both sides" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + zone_id = zone.id.as(String) + Model::Generator.metadata(name: "support-meta", parent: zone_id).save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Delete).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Delete).save! + + result = client.delete( + path: "#{Metadata.base_route}/#{zone_id}?name=support-meta", + headers: headers, + ) + result.status_code.should eq 202 + Model::Metadata.for(zone_id, "support-meta").first?.should be_nil + + zone.destroy + end + + it "rejects DELETE metadata on a zone parent when the user only has support Update" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + zone_id = zone.id.as(String) + Model::Generator.metadata(name: "support-meta", parent: zone_id).save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Update).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Update).save! + + result = client.delete( + path: "#{Metadata.base_route}/#{zone_id}?name=support-meta", + headers: headers, + ) + result.status_code.should eq 403 + + zone.destroy + end + + it "allows a support JWT to bypass the destroy gate" do + zone = Model::Generator.zone.save! + zone_id = zone.id.as(String) + Model::Generator.metadata(name: "support-meta", parent: zone_id).save! + + _, support_header = Spec::Authentication.authentication(sys_admin: false, support: true) + + result = client.delete( + path: "#{Metadata.base_route}/#{zone_id}?name=support-meta", + headers: support_header, + ) + result.status_code.should eq 202 + Model::Metadata.for(zone_id, "support-meta").first?.should be_nil + + zone.destroy + end + + # ---------------------------------------------------------------- + # 4. admin bypass + # ---------------------------------------------------------------- + + it "allows an admin to bypass support gating on PATCH and DELETE" do + zone = Model::Generator.zone.save! + zone_id = zone.id.as(String) + Model::Generator.metadata(name: "support-meta", parent: zone_id).save! + + meta = Model::Metadata::Interface.new( + name: "support-meta", + description: "", + details: JSON.parse(%({"hello":"world"})), + parent_id: nil, + editors: Set(String).new, + ) + + result = client.patch( + path: "#{Metadata.base_route}/#{zone_id}", + body: meta.to_json, + headers: Spec::Authentication.headers, + ) + result.status_code.should eq 200 + + result = client.delete( + path: "#{Metadata.base_route}/#{zone_id}?name=support-meta", + headers: Spec::Authentication.headers, + ) + result.status_code.should eq 202 + + zone.destroy + end + end + describe "scopes" do context "read" do scope_name = "metadata" diff --git a/spec/controllers/modules_spec.cr b/spec/controllers/modules_spec.cr index f94bd21d..7bac7132 100644 --- a/spec/controllers/modules_spec.cr +++ b/spec/controllers/modules_spec.cr @@ -490,5 +490,357 @@ module PlaceOS::Api mod.running.should be_false end end + + describe "support-subsystem permissions" do + ::Spec.before_each { clear_group_tables } + + # Build a logic module attached to a control system whose only zone is + # `zone`. The module derives its zones from that system, so granting on + # `zone` (via a "support" GroupZone) gates access to the module. + support_setup = ->(zone : Model::Zone) { + driver = Model::Generator.driver(role: Model::Driver::Role::Logic).save! + control_system = Model::Generator.control_system + control_system.zones = [zone.id.as(String)] + control_system.save! + mod = Model::Generator.module(driver: driver, control_system: control_system) + mod.running = false + mod.save! + mod + } + + it "allows POST create for a user granted Create on the module's system zone" 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, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Create).save! + + zone = Model::Generator.zone.save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Create).save! + + # a system the new logic module attaches to, scoped to `zone` + control_system = Model::Generator.control_system + control_system.zones = [zone.id.as(String)] + control_system.save! + + driver = Model::Generator.driver(role: Model::Driver::Role::Logic).save! + mod = Model::Generator.module(driver: driver, control_system: control_system) + mod.running = false + + result = client.post(Modules.base_route, body: mod.to_json, headers: headers) + result.status_code.should eq 201 + + created = Model::Module.from_trusted_json(result.body) + created.destroy + zone.destroy + end + + it "rejects POST create when the user has only Read on the system zone" 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, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + + zone = Model::Generator.zone.save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Read).save! + + control_system = Model::Generator.control_system + control_system.zones = [zone.id.as(String)] + control_system.save! + + driver = Model::Generator.driver(role: Model::Driver::Role::Logic).save! + mod = Model::Generator.module(driver: driver, control_system: control_system) + mod.running = false + + result = client.post(Modules.base_route, body: mod.to_json, headers: headers) + result.status_code.should eq 403 + zone.destroy + end + + it "allows PATCH update with Update on both sides, rejects with only Read" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, update_headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + mod = support_setup.call(zone) + path = File.join(Modules.base_route, mod.id.as(String)) + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + gu = Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Update).save! + gz = Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Update).save! + + result = client.patch(path: path, body: mod.to_json, headers: update_headers) + result.status_code.should eq 200 + + # downgrade both sides to Read only => denied + gu.permissions = Model::Permissions::Read.to_i + gu.save! + gz.permissions = Model::Permissions::Read.to_i + gz.save! + + result = client.patch(path: path, body: mod.to_json, headers: update_headers) + result.status_code.should eq 403 + + mod.destroy + zone.destroy + end + + it "allows DELETE with Delete on both sides, rejects without" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + # Only Read => destroy denied + gu = Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + gz = Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Read).save! + + mod = support_setup.call(zone) + path = File.join(Modules.base_route, mod.id.as(String)) + + result = client.delete(path: path, headers: headers) + result.status_code.should eq 403 + Model::Module.find?(mod.id.as(String)).should_not be_nil + + # grant Delete on both sides => allowed + gu.permissions = Model::Permissions::Delete.to_i + gu.save! + gz.permissions = Model::Permissions::Delete.to_i + gz.save! + + result = client.delete(path: path, headers: headers) + result.success?.should be_true + Model::Module.find?(mod.id.as(String)).should be_nil + + zone.destroy + end + + it "rejects mutation of a module whose zones the user has no GroupZone reach for" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + # user has Manage on the group/zone A, but the module lives in zone B + zone_a = Model::Generator.zone.save! + zone_b = Model::Generator.zone.save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Manage).save! + Model::Generator.group_zone(group: group, zone: zone_a, permissions: Model::Permissions::Manage).save! + + mod = support_setup.call(zone_b) + path = File.join(Modules.base_route, mod.id.as(String)) + + result = client.delete(path: path, headers: headers) + result.status_code.should eq 403 + Model::Module.find?(mod.id.as(String)).should_not be_nil + + mod.destroy + zone_a.destroy + zone_b.destroy + end + + it "gates start/stop on the Operate bit" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + mod = support_setup.call(zone) + start_path = File.join(Modules.base_route, mod.id.as(String), "start") + stop_path = File.join(Modules.base_route, mod.id.as(String), "stop") + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + # Delete bit is present but Operate is not => start denied + gu = Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Delete).save! + gz = Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Delete).save! + + result = client.post(path: start_path, headers: headers) + result.status_code.should eq 403 + mod.reload! + mod.running.should be_false + + # grant Operate on both sides => start allowed + gu.permissions = Model::Permissions::Operate.to_i + gu.save! + gz.permissions = Model::Permissions::Operate.to_i + gz.save! + + result = client.post(path: start_path, headers: headers) + result.status_code.should eq 200 + mod.reload! + mod.running.should be_true + + result = client.post(path: stop_path, headers: headers) + result.status_code.should eq 200 + mod.reload! + mod.running.should be_false + + mod.destroy + zone.destroy + end + + it "lets a support-JWT user start regardless of group grants" do + zone = Model::Generator.zone.save! + mod = support_setup.call(zone) + start_path = File.join(Modules.base_route, mod.id.as(String), "start") + + result = client.post( + path: start_path, + headers: Spec::Authentication.headers(sys_admin: false, support: true), + ) + result.status_code.should eq 200 + mod.reload! + mod.running.should be_true + + mod.destroy + zone.destroy + end + + it "lets admin and support JWT users create and destroy without any group" do + zone = Model::Generator.zone.save! + + control_system = Model::Generator.control_system + control_system.zones = [zone.id.as(String)] + control_system.save! + + # support JWT create + driver = Model::Generator.driver(role: Model::Driver::Role::Logic).save! + mod = Model::Generator.module(driver: driver, control_system: control_system) + mod.running = false + + result = client.post( + Modules.base_route, + body: mod.to_json, + headers: Spec::Authentication.headers(sys_admin: false, support: true), + ) + result.status_code.should eq 201 + created = Model::Module.from_trusted_json(result.body) + + # admin JWT destroy + result = client.delete( + path: File.join(Modules.base_route, created.id.as(String)), + headers: Spec::Authentication.headers(sys_admin: true, support: true), + ) + result.success?.should be_true + Model::Module.find?(created.id.as(String)).should be_nil + + zone.destroy + end + + # ---- read scoping ---- + + # A non-logic (device) module referenced by a control system scoped to + # `zone`. Device modules are reachable via the system's `modules` array, + # so the module derives its zones from that system. + device_in_zone = ->(zone : Model::Zone) { + driver = Model::Generator.driver(role: Model::Driver::Role::Device).save! + mod = Model::Generator.module(driver: driver).save! + cs = Model::Generator.control_system + cs.zones = [zone.id.as(String)] + cs.modules = [mod.id.as(String)] + cs.save! + {cs, mod} + } + + it "allows show for a support user with Read on the module's zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + cs, mod = device_in_zone.call(zone) + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Read).save! + + result = client.get(path: "#{Modules.base_route}#{mod.id}", headers: headers) + result.status_code.should eq 200 + + mod.destroy + cs.destroy + zone.destroy + end + + it "rejects show for a support user without a grant on the module's zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + cs, mod = device_in_zone.call(zone) + + # group exists but has no GroupZone reach to the module's zone + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + + result = client.get(path: "#{Modules.base_route}#{mod.id}", headers: headers) + result.status_code.should eq 403 + + mod.destroy + cs.destroy + zone.destroy + end + + it "allows index?control_system_id= with Read on the system zones and lists the modules" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + cs, mod = device_in_zone.call(zone) + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Read).save! + + result = client.get(path: "#{Modules.base_route}?control_system_id=#{cs.id}", headers: headers) + result.status_code.should eq 200 + JSON.parse(result.body).as_a.map(&.["id"].as_s).should contain(mod.id.as(String)) + + mod.destroy + cs.destroy + zone.destroy + end + + it "rejects index?control_system_id= for a support user without a grant" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + cs, mod = device_in_zone.call(zone) + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + + result = client.get(path: "#{Modules.base_route}?control_system_id=#{cs.id}", headers: headers) + result.status_code.should eq 403 + + mod.destroy + cs.destroy + zone.destroy + end + + it "gates the whole module list: 403 with no accessible zones, 200 (scoped) with a grant" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + cs, mod = device_in_zone.call(zone) + + # no group/grant => no accessible zones => 403 + client.get(path: Modules.base_route, headers: headers).status_code.should eq 403 + + # grant a reachable zone => allowed (result set is zone-scoped; contents + # depend on ES indexing, so we only assert the gate opens) + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Read).save! + + client.get(path: Modules.base_route, headers: headers).status_code.should eq 200 + + mod.destroy + cs.destroy + zone.destroy + end + end end end diff --git a/spec/controllers/settings_spec.cr b/spec/controllers/settings_spec.cr index 1d8ae176..800aebd3 100644 --- a/spec/controllers/settings_spec.cr +++ b/spec/controllers/settings_spec.cr @@ -187,6 +187,366 @@ module PlaceOS::Api end end + describe "support subsystem permissions" do + ::Spec.before_each { clear_group_tables } + + # ---------------------------------------------------------------- + # 1. show / index(?parent_id=zone-…) gated on Read + # ---------------------------------------------------------------- + + it "allows show for a regular user with support Read on the parent zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + setting = Model::Generator.settings(encryption_level: Encryption::Level::None, zone: zone) + setting.settings_string = "tree: 1" + setting.save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Read).save! + + result = client.get( + path: File.join(Settings.base_route, setting.id.as(String)), + headers: headers, + ) + result.status_code.should eq 200 + + zone.destroy + end + + it "allows index(?parent_id=zone-…) for a regular user with support Read on the parent zone" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + setting = Model::Generator.settings(encryption_level: Encryption::Level::None, zone: zone) + setting.settings_string = "tree: 1" + setting.save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Read).save! + + result = client.get( + path: File.join(Settings.base_route, "?parent_id=#{zone.id}"), + headers: headers, + ) + result.status_code.should eq 200 + + zone.destroy + end + + it "rejects show for a regular user with no support grant on the parent zone" do + _, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + setting = Model::Generator.settings(encryption_level: Encryption::Level::None, zone: zone) + setting.settings_string = "tree: 1" + setting.save! + + result = client.get( + path: File.join(Settings.base_route, setting.id.as(String)), + headers: headers, + ) + result.status_code.should eq 403 + + zone.destroy + end + + # ---------------------------------------------------------------- + # 2. create an UNENCRYPTED setting needs Create on both sides + # ---------------------------------------------------------------- + + it "allows create of an unencrypted setting with support Create on both sides" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Create).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Create).save! + + setting = Model::Generator.settings(encryption_level: Encryption::Level::None, zone: zone) + setting.settings_string = "tree: 1" + + result = client.post( + path: Settings.base_route, + body: setting.to_json, + headers: headers, + ) + result.status_code.should eq 201 + + created = Model::Settings.from_trusted_json(result.body) + created.destroy + zone.destroy + end + + it "rejects create of an unencrypted setting when the user only has support Read" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Read).save! + + setting = Model::Generator.settings(encryption_level: Encryption::Level::None, zone: zone) + setting.settings_string = "tree: 1" + + result = client.post( + path: Settings.base_route, + body: setting.to_json, + headers: headers, + ) + result.status_code.should eq 403 + + zone.destroy + end + + # ---------------------------------------------------------------- + # 3. update needs Update; destroy needs Delete (both sides) + # ---------------------------------------------------------------- + + it "allows update of an unencrypted setting with support Update on both sides" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + setting = Model::Generator.settings(encryption_level: Encryption::Level::None, zone: zone) + setting.settings_string = "tree: 1" + setting.save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Update).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Update).save! + + setting.settings_string = %(hello: "world"\n) + result = client.patch( + path: File.join(Settings.base_route, setting.id.as(String)), + body: setting.to_json, + headers: headers, + ) + result.status_code.should eq 200 + + zone.destroy + end + + it "rejects update of an unencrypted setting when the user only has support Read" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + setting = Model::Generator.settings(encryption_level: Encryption::Level::None, zone: zone) + setting.settings_string = "tree: 1" + setting.save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Read).save! + + setting.settings_string = %(hello: "world"\n) + result = client.patch( + path: File.join(Settings.base_route, setting.id.as(String)), + body: setting.to_json, + headers: headers, + ) + result.status_code.should eq 403 + + zone.destroy + end + + it "allows destroy of an unencrypted setting with support Delete on both sides" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + setting = Model::Generator.settings(encryption_level: Encryption::Level::None, zone: zone) + setting.settings_string = "tree: 1" + setting.save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Delete).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Delete).save! + + result = client.delete( + path: File.join(Settings.base_route, setting.id.as(String)), + headers: headers, + ) + result.status_code.should eq 202 + Model::Settings.find?(setting.id.as(String)).should be_nil + + zone.destroy + end + + it "rejects destroy of an unencrypted setting when the user only has support Update" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + setting = Model::Generator.settings(encryption_level: Encryption::Level::None, zone: zone) + setting.settings_string = "tree: 1" + setting.save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Update).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Update).save! + + result = client.delete( + path: File.join(Settings.base_route, setting.id.as(String)), + headers: headers, + ) + result.status_code.should eq 403 + + zone.destroy + end + + # ---------------------------------------------------------------- + # 4. ENCRYPTED settings: non-admin can never modify, admin can + # ---------------------------------------------------------------- + + it "rejects modify of an encrypted setting for a non-admin support-group user" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + setting = Model::Generator.settings(encryption_level: Encryption::Level::Admin, zone: zone) + setting.settings_string = "tree: 1" + setting.save! + + # full support grant on both sides — still denied because encrypted + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Manage).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Manage).save! + + result = client.delete( + path: File.join(Settings.base_route, setting.id.as(String)), + headers: headers, + ) + result.status_code.should eq 403 + + setting.destroy + zone.destroy + end + + it "allows an admin to modify an encrypted setting" do + zone = Model::Generator.zone.save! + setting = Model::Generator.settings(encryption_level: Encryption::Level::Admin, zone: zone) + setting.settings_string = "tree: 1" + setting.save! + + result = client.delete( + path: File.join(Settings.base_route, setting.id.as(String)), + headers: Spec::Authentication.headers, + ) + result.status_code.should eq 202 + Model::Settings.find?(setting.id.as(String)).should be_nil + + zone.destroy + end + + # ---------------------------------------------------------------- + # 5. driver- parent: no zones => admin only + # ---------------------------------------------------------------- + + it "rejects modify of a driver- parent setting for a support-group user (no zones)" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + driver = Model::Generator.driver.save! + setting = Model::Generator.settings(encryption_level: Encryption::Level::None, driver: driver) + setting.settings_string = "tree: 1" + setting.save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Manage).save! + + result = client.delete( + path: File.join(Settings.base_route, setting.id.as(String)), + headers: headers, + ) + result.status_code.should eq 403 + + setting.destroy + driver.destroy + end + + it "allows an admin to modify a driver- parent setting" do + driver = Model::Generator.driver.save! + setting = Model::Generator.settings(encryption_level: Encryption::Level::None, driver: driver) + setting.settings_string = "tree: 1" + setting.save! + + result = client.delete( + path: File.join(Settings.base_route, setting.id.as(String)), + headers: Spec::Authentication.headers, + ) + result.status_code.should eq 202 + Model::Settings.find?(setting.id.as(String)).should be_nil + + driver.destroy + end + + # ---------------------------------------------------------------- + # 6. sys- parent create, and admin bypass + # ---------------------------------------------------------------- + + it "allows create of an unencrypted setting on a sys- parent with support Create on both sides" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + sys = Model::Generator.control_system + sys.zones = [zone.id.as(String)] + sys.save! + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Create).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Create).save! + + setting = Model::Generator.settings(encryption_level: Encryption::Level::None, control_system: sys) + setting.settings_string = "tree: 1" + + result = client.post( + path: Settings.base_route, + body: setting.to_json, + headers: headers, + ) + result.status_code.should eq 201 + + created = Model::Settings.from_trusted_json(result.body) + created.destroy + sys.destroy + zone.destroy + end + + it "allows an admin to bypass support gating on create and destroy" do + zone = Model::Generator.zone.save! + + setting = Model::Generator.settings(encryption_level: Encryption::Level::None, zone: zone) + setting.settings_string = "tree: 1" + + result = client.post( + path: Settings.base_route, + body: setting.to_json, + headers: Spec::Authentication.headers, + ) + result.status_code.should eq 201 + created = Model::Settings.from_trusted_json(result.body) + + result = client.delete( + path: File.join(Settings.base_route, created.id.as(String)), + headers: Spec::Authentication.headers, + ) + result.status_code.should eq 202 + + zone.destroy + end + end + describe "scopes" do Spec.test_controller_scope(Settings) diff --git a/spec/controllers/system-triggers_spec.cr b/spec/controllers/system-triggers_spec.cr index 911881eb..8a050b72 100644 --- a/spec/controllers/system-triggers_spec.cr +++ b/spec/controllers/system-triggers_spec.cr @@ -125,5 +125,158 @@ module PlaceOS::Api Model::TriggerInstance.find?(id.as(String)).should be_nil end end + + describe "support-subsystem permissions" do + ::Spec.before_each { clear_group_tables } + + # A control system scoped to `zone`, plus a persisted trigger instance + # attached to it. The instance inherits the system's zones, so granting + # on `zone` (via a "support" GroupZone) gates index/show/mutations. + support_system_setup = ->(zone : Model::Zone) { + sys = Model::Generator.control_system + sys.zones = [zone.id.as(String)] + sys.save! + trigger_instance = Model::Generator.trigger_instance + trigger_instance.control_system = sys + trigger_instance.save! + {sys, trigger_instance} + } + + it "allows GET index/show with Read on the system zone, rejects without" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + sys, trigger_instance = support_system_setup.call(zone) + + base = SystemTriggers.base_route.gsub(/:sys_id/, sys.id.as(String)) + show_path = base + trigger_instance.id.as(String) + + # no group reach yet => denied + result = client.get(path: base, headers: headers) + result.status_code.should eq 403 + result = client.get(path: show_path, headers: headers) + result.status_code.should eq 403 + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Read).save! + + result = client.get(path: show_path, headers: headers) + result.status_code.should eq 200 + Model::TriggerInstance.from_trusted_json(result.body).id.should eq trigger_instance.id + + trigger_instance.destroy + sys.destroy + zone.destroy + end + + it "allows POST create with Create on both sides, rejects with only Read" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + sys = Model::Generator.control_system + sys.zones = [zone.id.as(String)] + sys.save! + + base = SystemTriggers.base_route.gsub(/:sys_id/, sys.id.as(String)) + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + gu = Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + gz = Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Read).save! + + trigger_instance = Model::Generator.trigger_instance + trigger_instance.control_system = sys + body = trigger_instance.to_json + + # only Read => create denied + result = client.post(path: base, body: body, headers: headers) + result.status_code.should eq 403 + + # grant Create on both sides => allowed + gu.permissions = Model::Permissions::Create.to_i + gu.save! + gz.permissions = Model::Permissions::Create.to_i + gz.save! + + result = client.post(path: base, body: body, headers: headers) + result.status_code.should eq 201 + Model::TriggerInstance.find?(JSON.parse(result.body)["id"].as_s).try &.destroy + + sys.destroy + zone.destroy + end + + it "gates PATCH update on Update and DELETE on Delete" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + zone = Model::Generator.zone.save! + sys, trigger_instance = support_system_setup.call(zone) + + base = SystemTriggers.base_route.gsub(/:sys_id/, sys.id.as(String)) + path = base + trigger_instance.id.as(String) + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + gu = Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + gz = Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Read).save! + + # only Read => update denied + result = client.patch(path: path, body: {important: true}.to_json, headers: headers) + result.status_code.should eq 403 + + # Update on both sides => allowed + gu.permissions = Model::Permissions::Update.to_i + gu.save! + gz.permissions = Model::Permissions::Update.to_i + gz.save! + result = client.patch(path: path, body: {important: true}.to_json, headers: headers) + result.status_code.should eq 200 + + # Update does not grant Delete => destroy denied + result = client.delete(path: path, headers: headers) + result.status_code.should eq 403 + Model::TriggerInstance.find?(trigger_instance.id.as(String)).should_not be_nil + + # Delete on both sides => allowed + gu.permissions = Model::Permissions::Delete.to_i + gu.save! + gz.permissions = Model::Permissions::Delete.to_i + gz.save! + result = client.delete(path: path, headers: headers) + result.success?.should be_true + Model::TriggerInstance.find?(trigger_instance.id.as(String)).should be_nil + + sys.destroy + zone.destroy + end + + it "lets admin/support JWT users bypass the support gate" do + zone = Model::Generator.zone.save! + sys, trigger_instance = support_system_setup.call(zone) + + base = SystemTriggers.base_route.gsub(/:sys_id/, sys.id.as(String)) + show_path = base + trigger_instance.id.as(String) + + # support JWT can read without any group + result = client.get( + path: show_path, + headers: Spec::Authentication.headers(sys_admin: false, support: true), + ) + result.status_code.should eq 200 + + # admin JWT can destroy without any group + result = client.delete( + path: show_path, + headers: Spec::Authentication.headers(sys_admin: true, support: true), + ) + result.success?.should be_true + Model::TriggerInstance.find?(trigger_instance.id.as(String)).should be_nil + + sys.destroy + zone.destroy + end + end end end diff --git a/spec/controllers/uploads_spec.cr b/spec/controllers/uploads_spec.cr index b87b47a6..3385afe5 100644 --- a/spec/controllers/uploads_spec.cr +++ b/spec/controllers/uploads_spec.cr @@ -423,6 +423,94 @@ module PlaceOS::Api result.status_code.should eq(403) end + describe "support subsystem permissions" do + # `finished` (PUT) is a DB-only mutation (no S3 round-trip) — ideal for + # exercising the ALLOW path of `confirm_upload_access`. DELETE is used + # only for DENY cases, where the gate raises before any storage call, + # so these tests never touch S3/minio. Cleanup is a table truncate + # (no per-row `destroy`, hence no S3) via `.clear`. + ::Spec.before_each do + clear_group_tables + Model::Upload.clear + Model::Storage.clear + end + + it "lets the owner mark their own upload finished without any group" do + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + storage = Model::Generator.storage.save! + upload = Model::Generator.upload(uploader: user, storage_id: storage.id).save! + upload_id = upload.id.as(String) + + result = client.put(path: "#{Uploads.base_route}/#{upload_id}", headers: headers) + result.status_code.should eq 200 + Model::Upload.find!(upload_id).upload_complete.should be_true + end + + it "rejects a non-owner regular user mutating someone else's upload (PUT and DELETE)" do + _, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + owner = Model::Generator.user.save! + storage = Model::Generator.storage.save! + upload = Model::Generator.upload(uploader: owner, storage_id: storage.id).save! + upload_id = upload.id.as(String) + + client.put(path: "#{Uploads.base_route}/#{upload_id}", headers: headers).status_code.should eq 403 + # DELETE is likewise blocked before any storage call. + client.delete(path: "#{Uploads.base_route}/#{upload_id}", headers: headers).status_code.should eq 403 + Model::Upload.find?(upload_id).should_not be_nil + end + + it "lets a non-owner with a support Update grant on the org zone mark finished (PUT)" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Update).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Update).save! + + owner = Model::Generator.user.save! + storage = Model::Generator.storage.save! + upload = Model::Generator.upload(uploader: owner, storage_id: storage.id).save! + upload_id = upload.id.as(String) + + result = client.put(path: "#{Uploads.base_route}/#{upload_id}", headers: headers) + result.status_code.should eq 200 + Model::Upload.find!(upload_id).upload_complete.should be_true + end + + it "rejects DELETE for a non-owner whose support grant lacks the Delete bit" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + # Update (not Delete) on both sides: PUT/finished allowed, DELETE denied. + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Update).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Update).save! + + owner = Model::Generator.user.save! + storage = Model::Generator.storage.save! + upload = Model::Generator.upload(uploader: owner, storage_id: storage.id).save! + upload_id = upload.id.as(String) + + client.delete(path: "#{Uploads.base_route}/#{upload_id}", headers: headers).status_code.should eq 403 + Model::Upload.find?(upload_id).should_not be_nil + end + + it "lets a support-JWT non-owner mutate another user's upload (PUT)" do + owner = Model::Generator.user.save! + storage = Model::Generator.storage.save! + upload = Model::Generator.upload(uploader: owner, storage_id: storage.id).save! + upload_id = upload.id.as(String) + + result = client.put( + path: "#{Uploads.base_route}/#{upload_id}", + headers: Spec::Authentication.headers(sys_admin: false, support: true), + ) + result.status_code.should eq 200 + end + end + it "should allow access to global storage (authority_id is nil)" do authority = Model::Authority.find_by_domain("localhost").not_nil! diff --git a/spec/controllers/users_spec.cr b/spec/controllers/users_spec.cr index 837686ca..12431d65 100644 --- a/spec/controllers/users_spec.cr +++ b/spec/controllers/users_spec.cr @@ -215,6 +215,251 @@ module PlaceOS::Api Spec.test_controller_scope(Users) end + describe "support subsystem permissions" do + ::Spec.before_each { clear_group_tables } + + it "allows a support-subsystem user with Create grant to POST /users" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Create).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Create).save! + + new_user = Model::Generator.user(authority) + result = client.post( + path: Users.base_route, + body: new_user.to_json, + headers: headers, + ) + result.status_code.should eq 201 + + created = Model::User.from_trusted_json(result.body) + created.authority_id.should eq authority.id + Model::User.find?(created.id.as(String)).should_not be_nil + created.destroy + end + + it "rejects POST /users for a regular user without a support grant" do + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + authority = Model::Authority.find_by_domain("localhost").not_nil! + + new_user = Model::Generator.user(authority) + result = client.post( + path: Users.base_route, + body: new_user.to_json, + headers: headers, + ) + result.status_code.should eq 403 + end + + it "rejects POST /users when the user-side has the bit but no GroupZone reach" 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, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Create).save! + # No GroupZone on the org zone — AND semantics deny. + + new_user = Model::Generator.user(authority) + result = client.post( + path: Users.base_route, + body: new_user.to_json, + headers: headers, + ) + result.status_code.should eq 403 + end + + it "allows a support-subsystem user with Delete grant to DELETE a user" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Delete).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Delete).save! + + target = Model::Generator.user(authority).save! + target_id = target.id.as(String) + + result = client.delete( + path: File.join(Users.base_route, target_id), + headers: headers, + ) + result.success?.should be_true + Model::User.find?(target_id).should be_nil + end + + it "rejects DELETE for a support-subsystem user holding only Create (not Delete)" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Create).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Create).save! + + target = Model::Generator.user(authority).save! + target_id = target.id.as(String) + + result = client.delete( + path: File.join(Users.base_route, target_id), + headers: headers, + ) + result.status_code.should eq 403 + + target.destroy + end + + it "allows a support-subsystem user with Update grant to update another user" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Update).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Update).save! + + target = Model::Generator.user(authority).save! + target_id = target.id.as(String) + + result = client.patch( + path: File.join(Users.base_route, target_id), + body: {name: "Support Updated"}.to_json, + headers: headers, + ) + result.status_code.should eq 200 + Model::User.from_trusted_json(result.body).name.should eq "Support Updated" + + target.destroy + end + + it "allows the self-update path without any support grant" do + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + user_id = user.id.as(String) + + result = client.patch( + path: File.join(Users.base_route, user_id), + body: {name: "Self Updated"}.to_json, + headers: headers, + ) + result.status_code.should eq 200 + Model::User.from_trusted_json(result.body).name.should eq "Self Updated" + end + + it "rejects updating another user with neither self, admin, nor a support grant" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + target = Model::Generator.user(authority).save! + target_id = target.id.as(String) + + result = client.patch( + path: File.join(Users.base_route, target_id), + body: {name: "Should Fail"}.to_json, + headers: headers, + ) + result.status_code.should eq 403 + + target.destroy + end + + it "keeps resource-token routes admin-only (support subsystem denied)" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + org_zone = Spec::Authentication.org_zone + + # Full Manage support grant — still must not reach resource-token routes. + group = Model::Generator.group(authority: authority, subsystems: ["support"]).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Manage).save! + Model::Generator.group_zone(group: group, zone: org_zone, permissions: Model::Permissions::Manage).save! + + target = Model::Generator.user(authority).save! + target_id = target.id.as(String) + + result = client.delete( + path: File.join(Users.base_route, target_id, "resource_token"), + headers: headers, + ) + result.status_code.should eq 403 + + target.destroy + end + + it "keeps resource-token routes admin-only (support JWT denied)" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + target = Model::Generator.user(authority).save! + target_id = target.id.as(String) + + result = client.delete( + path: File.join(Users.base_route, target_id, "resource_token"), + headers: Spec::Authentication.headers(sys_admin: false, support: true), + ) + result.status_code.should eq 403 + + target.destroy + end + + it "allows admins to use the delete resource-token route" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + target = Model::Generator.user(authority).save! + target_id = target.id.as(String) + + result = client.delete( + path: File.join(Users.base_route, target_id, "resource_token"), + headers: Spec::Authentication.headers(sys_admin: true), + ) + result.status_code.should eq 202 + + target.destroy + end + + it "allows a support JWT to create and destroy users (role bypass)" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + support_headers = Spec::Authentication.headers(sys_admin: false, support: true) + + new_user = Model::Generator.user(authority) + result = client.post( + path: Users.base_route, + body: new_user.to_json, + headers: support_headers, + ) + result.status_code.should eq 201 + created = Model::User.from_trusted_json(result.body) + created_id = created.id.as(String) + + result = client.delete( + path: File.join(Users.base_route, created_id), + headers: support_headers, + ) + result.success?.should be_true + Model::User.find?(created_id).should be_nil + end + + it "allows an admin JWT to create and destroy users (role bypass)" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + admin_headers = Spec::Authentication.headers(sys_admin: true) + + new_user = Model::Generator.user(authority) + result = client.post( + path: Users.base_route, + body: new_user.to_json, + headers: admin_headers, + ) + result.status_code.should eq 201 + created = Model::User.from_trusted_json(result.body) + created_id = created.id.as(String) + + result = client.delete( + path: File.join(Users.base_route, created_id), + headers: admin_headers, + ) + result.success?.should be_true + Model::User.find?(created_id).should be_nil + end + end + describe "GET /metadata/search" do it "renders the JSON path search results" do schema = <<-J diff --git a/src/app.cr b/src/app.cr index 5c6681cc..63f0eca7 100644 --- a/src/app.cr +++ b/src/app.cr @@ -87,6 +87,7 @@ end # SELECTs outside of a transaction are sent to the replica while all writes and # in-transaction reads continue to use the primary connection (PG_DATABASE_URL). PgORM::Database.parse_read(ENV["PG_DATABASE_READ_URL"]?) +PgORM::Settings.to_uri # Load the routes PlaceOS::Api::Log.info { "launching #{PlaceOS::Api::APP_NAME} v#{PlaceOS::Api::VERSION} (#{PlaceOS::Api::BUILD_COMMIT} @ #{PlaceOS::Api::BUILD_TIME.strip})" } diff --git a/src/placeos-rest-api/controllers/asset_categories.cr b/src/placeos-rest-api/controllers/asset_categories.cr index c18cda38..c13a092a 100644 --- a/src/placeos-rest-api/controllers/asset_categories.cr +++ b/src/placeos-rest-api/controllers/asset_categories.cr @@ -3,6 +3,7 @@ require "./application" module PlaceOS::Api class AssetCategories < Application include Utils::Permissions + include Utils::GroupPermissions base "/api/engine/v2/asset_categories/" @@ -19,6 +20,8 @@ module PlaceOS::Api authority = current_authority.as(::PlaceOS::Model::Authority) if zone_id = authority.config["org_zone"]?.try(&.as_s?) + # "support" subsystem: the verb's bit on the org zone. + return if support_subsystem_grants?([zone_id], verb_permission) access = check_access(current_user.groups, [zone_id]) return if access.can_manage? end diff --git a/src/placeos-rest-api/controllers/asset_purchase_orders.cr b/src/placeos-rest-api/controllers/asset_purchase_orders.cr index 5b2ce1ad..8fbbf1ed 100644 --- a/src/placeos-rest-api/controllers/asset_purchase_orders.cr +++ b/src/placeos-rest-api/controllers/asset_purchase_orders.cr @@ -3,6 +3,7 @@ require "./application" module PlaceOS::Api class AssetPurchaseOrders < Application include Utils::Permissions + include Utils::GroupPermissions base "/api/engine/v2/asset_purchase_orders/" @@ -19,6 +20,8 @@ module PlaceOS::Api authority = current_authority.as(::PlaceOS::Model::Authority) if zone_id = authority.config["org_zone"]?.try(&.as_s?) + # "support" subsystem: the verb's bit on the org zone. + return if support_subsystem_grants?([zone_id], verb_permission) access = check_access(current_user.groups, [zone_id]) return if access.can_manage? end diff --git a/src/placeos-rest-api/controllers/asset_types.cr b/src/placeos-rest-api/controllers/asset_types.cr index d12f1659..d3293308 100644 --- a/src/placeos-rest-api/controllers/asset_types.cr +++ b/src/placeos-rest-api/controllers/asset_types.cr @@ -3,6 +3,7 @@ require "./application" module PlaceOS::Api class AssetTypes < Application include Utils::Permissions + include Utils::GroupPermissions base "/api/engine/v2/asset_types/" @@ -19,6 +20,8 @@ module PlaceOS::Api authority = current_authority.as(::PlaceOS::Model::Authority) if zone_id = authority.config["org_zone"]?.try(&.as_s?) + # "support" subsystem: the verb's bit on the org zone. + return if support_subsystem_grants?([zone_id], verb_permission) access = check_access(current_user.groups, [zone_id]) return if access.can_manage? end diff --git a/src/placeos-rest-api/controllers/assets.cr b/src/placeos-rest-api/controllers/assets.cr index 0469921c..62b9e1b4 100644 --- a/src/placeos-rest-api/controllers/assets.cr +++ b/src/placeos-rest-api/controllers/assets.cr @@ -3,6 +3,7 @@ require "./application" module PlaceOS::Api class Assets < Application include Utils::Permissions + include Utils::GroupPermissions base "/api/engine/v2/assets/" @@ -27,6 +28,10 @@ module PlaceOS::Api private def confirm_access return if user_support? + # "support" subsystem: the verb's bit on the asset's zone(s). + asset_zones = ([current_asset.zone_id] + current_asset.zones).compact.uniq! + return if support_subsystem_grants?(asset_zones, verb_permission) + authority = current_authority.as(::PlaceOS::Model::Authority) if zone_id = authority.config["org_zone"]?.try(&.as_s?) @@ -165,6 +170,8 @@ module PlaceOS::Api @[AC::Route::POST("/bulk", body: :assets, status_code: HTTP::Status::CREATED)] def bulk_create(assets : Array(::PlaceOS::Model::Asset)) : Array(::PlaceOS::Model::Asset) assets.map do |asset| + @current_asset = asset + confirm_access raise Error::ModelValidation.new(asset.errors) unless asset.save asset end @@ -177,6 +184,7 @@ module PlaceOS::Api assets.compact_map do |asset| if asset_id = asset.id current = find_current_asset(asset_id) + confirm_access current.assign_attributes(asset) raise Error::ModelValidation.new(current.errors) unless current.save current @@ -189,6 +197,7 @@ module PlaceOS::Api def bulk_destroy(asset_ids : Array(String)) : Nil asset_ids.each do |asset_id| current = find_current_asset(asset_id) + confirm_access current.destroy end end diff --git a/src/placeos-rest-api/controllers/metadata.cr b/src/placeos-rest-api/controllers/metadata.cr index 9cef26f7..aa60f414 100644 --- a/src/placeos-rest-api/controllers/metadata.cr +++ b/src/placeos-rest-api/controllers/metadata.cr @@ -5,6 +5,7 @@ require "./application" module PlaceOS::Api class Metadata < Application include Utils::Permissions + include Utils::GroupPermissions base "/api/engine/v2/metadata" @@ -287,9 +288,14 @@ module PlaceOS::Api ids.uniq! end - def check_access_level(zone_id : String, admin_required : Bool = false) - # ensure this is a zone_id we're checking - raise Error::Forbidden.new unless zone_id.starts_with? "zone-" + def check_access_level(parent_id : String, admin_required : Bool = false) + # "support" subsystem: the verb's bit on the parent's zones. Handles + # zone-, sys- and mod- parents; user- parents have no zone scope and + # rely on the ownership/support shortcuts in the callers. + return if support_subsystem_grants?(support_zones_for_parent(parent_id), verb_permission) + + # Legacy org_zone scheme (zone parents only). + raise Error::Forbidden.new unless parent_id.starts_with? "zone-" # find the org zone authority = current_authority.as(::PlaceOS::Model::Authority) @@ -297,11 +303,11 @@ module PlaceOS::Api raise Error::Forbidden.new unless org_zone_id # check that the permissions apply to this zone - current_zone = ::PlaceOS::Model::Zone.find!(zone_id) + current_zone = ::PlaceOS::Model::Zone.find!(parent_id) root_zone_id = current_zone.root_zone_id if root_zone_id == org_zone_id - zones = [org_zone_id, zone_id].uniq! + zones = [org_zone_id, parent_id].uniq! access = check_access(current_user.groups, zones) if admin_required diff --git a/src/placeos-rest-api/controllers/modules.cr b/src/placeos-rest-api/controllers/modules.cr index a32b4f74..dd7e255d 100644 --- a/src/placeos-rest-api/controllers/modules.cr +++ b/src/placeos-rest-api/controllers/modules.cr @@ -11,6 +11,7 @@ module PlaceOS::Api class Modules < Application include Utils::CoreHelper include Utils::Permissions + include Utils::GroupPermissions base "/api/engine/v2/modules/" @@ -21,7 +22,10 @@ module PlaceOS::Api before_action :can_write, only: [:create, :update, :destroy, :remove] before_action :check_admin, except: [:index, :create, :update, :destroy, :state, :show, :ping, :start, :stop] - before_action :check_support, only: [:state, :show, :ping, :show_error, :start, :stop] + # start/stop are gated by the support-subsystem Operate check in-action; + # show is gated by `check_show_permissions` (support-subsystem Read on the + # module's zones) — so both are intentionally absent here. + before_action :check_support, only: [:state, :ping, :show_error] ############################################################################################### @@ -37,21 +41,35 @@ module PlaceOS::Api # Permissions ############################################################################################### + # Mutation gate. Admin/support JWT bypass; otherwise the "support" + # subsystem must grant the verb's permission on the module's zones + # (legacy org_zone path preserved). A module attached to no system has + # no derivable zones → admin/support only. + # NOTE:: if modifying, update Settings#can_modify? def can_modify?(mod) - return if user_admin? - # NOTE:: if modifying, update Settings#can_modify? - - cs_id = mod.control_system_id - raise Error::Forbidden.new unless cs_id + ensure_support_access!(zones_for_module(mod), verb_permission) + end - # find the org zone - authority = current_authority.as(::PlaceOS::Model::Authority) - org_zone_id = authority.config["org_zone"]?.try(&.as_s?) - raise Error::Forbidden.new unless org_zone_id + # Zones a module belongs to: the union of its logic-module system + # (`control_system_id`) and every system that references it. Empty when + # the module is attached to no system. + private def zones_for_module(mod : ::PlaceOS::Model::Module) : Array(String) + zones = [] of String + if (cs_id = mod.control_system_id) && (sys = ::PlaceOS::Model::ControlSystem.find?(cs_id)) + zones.concat(sys.zones) + end + if (mod_id = mod.id) + ::PlaceOS::Model::ControlSystem.by_module_id(mod_id).each { |cs| zones.concat(cs.zones) } + end + zones.uniq + end - zones = ::PlaceOS::Model::ControlSystem.find!(cs_id).zones - raise Error::Forbidden.new unless zones.includes?(org_zone_id) - raise Error::Forbidden.new unless check_access(current_user.groups, zones).admin? + # Reading a single module: admin/support JWT and the legacy org_zone path + # bypass; otherwise a "support" subsystem user needs Read on the module's + # zones. A module with no derivable zones stays admin/support only. + @[AC::Route::Filter(:before_action, only: [:show])] + def check_show_permissions + ensure_support_access!(zones_for_module(current_module), ::PlaceOS::Model::Permissions::Read) end @[AC::Route::Filter(:before_action, only: [:index])] @@ -59,24 +77,43 @@ module PlaceOS::Api @[AC::Param::Info(description: "only return modules running in this system (query params are ignored if this is provided)", example: "sys-1234")] control_system_id : String? = nil, ) + # admin/support JWT see everything (no scoping) return if user_support? - # find the org zone - authority = current_authority.as(::PlaceOS::Model::Authority) - @org_zone_id = org_zone_id = authority.config["org_zone"]?.try(&.as_s?) - raise Error::Forbidden.new unless org_zone_id + org_zone = support_org_zone_id if control_system_id zones = ::PlaceOS::Model::ControlSystem.find!(control_system_id).zones - raise Error::Forbidden.new unless zones.includes?(org_zone_id) - raise Error::Forbidden.new unless check_access(current_user.groups, zones).can_manage? - else - access = check_access(current_user.groups, [org_zone_id]) - raise Error::Forbidden.new unless access.can_manage? + # "support" subsystem: Read on the system's zones. + return if support_subsystem_grants?(zones, ::PlaceOS::Model::Permissions::Read) + # legacy org_zone path: the system must include the org_zone and the + # user must be able to manage it. + return if org_zone && zones.includes?(org_zone) && check_access(current_user.groups, zones).can_manage? + raise Error::Forbidden.new end + + # Whole-list view: instead of a flat allow/deny, resolve the zone set the + # caller may see and scope the result set to it (see `index`). + # legacy org_zone manager -> systems containing the org_zone. + if org_zone && check_access(current_user.groups, [org_zone]).can_manage? + @module_scope_zones = [org_zone] + return + end + + # "support" subsystem -> systems whose zones the caller can reach. + accessible = support_accessible_zone_ids + unless accessible.empty? + @module_scope_zones = accessible + return + end + + raise Error::Forbidden.new end - getter org_zone_id : String? = nil + # Zone ids used to scope the module list for non-admin/non-support callers + # (legacy org_zone, or the caller's reachable "support" zones). Left nil for + # admin/support callers, who see everything. + getter module_scope_zones : Array(String)? = nil # Response helpers ############################################################################################### @@ -138,18 +175,19 @@ module PlaceOS::Api # TODO:: we can remove this once there is a tenant_id field on modules # which will make this much simpler to filter - if filter_zone_id = org_zone_id - # we only want to show modules in use by systems that include this zone + if scope_zones = module_scope_zones + # we only want to show modules in use by systems within these zones no_logic = true # find all the non-logic modules that this user can access - # 1. grabs all the module ids in the systems of the provided org zone - # 2. select distinct modules ids which are not logic modules (99) + # 1. grab all the module ids in systems whose zones overlap scope_zones + # 2. select distinct module ids which are not logic modules (99) + placeholders = (1..scope_zones.size).map { |i| "$#{i}" }.join(", ") sql_query = %[ WITH matching_rows AS ( SELECT unnest(modules) AS module_id FROM sys - WHERE $1 = ANY(zones) + WHERE zones && ARRAY[#{placeholders}]::text[] ) SELECT ARRAY_AGG(DISTINCT m.module_id) @@ -159,7 +197,7 @@ module PlaceOS::Api ] module_ids = PgORM::Database.connection do |conn| - conn.query_one(sql_query, args: [filter_zone_id], &.read(Array(String)?)) + conn.query_one(sql_query, args: scope_zones.map(&.as(PgORM::Value)), &.read(Array(String)?)) end || [] of String query.must({ @@ -318,7 +356,7 @@ module PlaceOS::Api @[AC::Route::POST("/:id/start")] def start : Nil return if current_module.running == true - can_modify?(current_module) unless user_support? + ensure_support_access!(zones_for_module(current_module), ::PlaceOS::Model::Permissions::Operate) current_module.update_fields(running: true) # Changes cleared on a successful update @@ -332,7 +370,7 @@ module PlaceOS::Api @[AC::Route::POST("/:id/stop")] def stop : Nil return unless current_module.running - can_modify?(current_module) unless user_support? + ensure_support_access!(zones_for_module(current_module), ::PlaceOS::Model::Permissions::Operate) current_module.update_fields(running: false) # Changes cleared on a successful update diff --git a/src/placeos-rest-api/controllers/pending_mails.cr b/src/placeos-rest-api/controllers/pending_mails.cr index fe87f081..6a060aac 100644 --- a/src/placeos-rest-api/controllers/pending_mails.cr +++ b/src/placeos-rest-api/controllers/pending_mails.cr @@ -55,12 +55,8 @@ module PlaceOS::Api return if check_access(current_user.groups, [org_zone_id] + mail.zones).can_manage? end - # "support" subsystem: OR'd across the mail's zones (the Array overload - # of effective_permissions already does this). - perms = ::PlaceOS::Model::Group.effective_permissions( - authority.id.as(String), "support", current_user.id.as(String), mail.zones, - ) - return if perms.manage? || perms.includes?(required) + # "support" subsystem: OR'd across the mail's zones. + return if support_subsystem_grants?(mail.zones, required) raise Error::Forbidden.new end diff --git a/src/placeos-rest-api/controllers/settings.cr b/src/placeos-rest-api/controllers/settings.cr index 8695b53e..32bb3686 100644 --- a/src/placeos-rest-api/controllers/settings.cr +++ b/src/placeos-rest-api/controllers/settings.cr @@ -3,6 +3,7 @@ require "./application" module PlaceOS::Api class Settings < Application include Utils::Permissions + include Utils::GroupPermissions base "/api/engine/v2/settings/" @@ -13,7 +14,7 @@ module PlaceOS::Api before_action :can_write, only: [:create, :update, :destroy, :remove] # permissions are checked as part of the route - before_action :check_admin, except: [:index, :show, :create, :update] + before_action :check_admin, except: [:index, :show, :create, :update, :destroy] ############################################################################################### @@ -31,6 +32,8 @@ module PlaceOS::Api def can_view?(parent_id) return if user_support? + # "support" subsystem: Read on the parent's zones. + return if support_subsystem_grants?(support_zones_for_parent(parent_id), ::PlaceOS::Model::Permissions::Read) check_access_level(parent_id, admin_required: false) end @@ -38,6 +41,9 @@ module PlaceOS::Api return if user_admin? raise Error::Forbidden.new("can only modify unencrypted settings") unless setting.encryption_level.none? parent_id = setting.parent_id.as(String) + # "support" subsystem: the verb's bit on the parent's zones. A + # parent with no derivable zones (e.g. a driver) stays admin-only. + return if support_subsystem_grants?(support_zones_for_parent(parent_id), verb_permission) check_access_level(parent_id, admin_required: true) end @@ -136,6 +142,7 @@ module PlaceOS::Api # remove a setting @[AC::Route::DELETE("/:id", status_code: HTTP::Status::ACCEPTED)] def destroy : Nil + can_modify?(current_settings) current_settings.destroy end diff --git a/src/placeos-rest-api/controllers/system-triggers.cr b/src/placeos-rest-api/controllers/system-triggers.cr index 5587f8c5..422e0cc2 100644 --- a/src/placeos-rest-api/controllers/system-triggers.cr +++ b/src/placeos-rest-api/controllers/system-triggers.cr @@ -2,6 +2,9 @@ require "./application" module PlaceOS::Api class SystemTriggers < Application + include Utils::Permissions + include Utils::GroupPermissions + base "/api/engine/v2/systems/:sys_id/triggers/" # Scopes @@ -10,8 +13,21 @@ module PlaceOS::Api before_action :can_read, only: [:index, :show] before_action :can_write, only: [:create, :update, :destroy, :remove] - before_action :check_admin, only: [:create, :update, :destroy] - before_action :check_support, only: [:index, :show] + # Permissions + ############################################################################################### + + # A trigger instance inherits the zones of its parent control system. + # Reads need Read; mutations need the verb's bit (admin/support JWT and + # the legacy org_zone path bypass as usual). + @[AC::Route::Filter(:before_action, only: [:index, :show])] + def check_trigger_read_permissions(sys_id : String) + ensure_support_access!(::PlaceOS::Model::ControlSystem.find!(sys_id).zones, ::PlaceOS::Model::Permissions::Read) + end + + @[AC::Route::Filter(:before_action, only: [:create, :update, :destroy])] + def check_trigger_write_permissions(sys_id : String) + ensure_support_access!(::PlaceOS::Model::ControlSystem.find!(sys_id).zones, verb_permission) + end ############################################################################################### diff --git a/src/placeos-rest-api/controllers/systems.cr b/src/placeos-rest-api/controllers/systems.cr index 7857aff7..472b9c03 100644 --- a/src/placeos-rest-api/controllers/systems.cr +++ b/src/placeos-rest-api/controllers/systems.cr @@ -148,55 +148,6 @@ module PlaceOS::Api raise Error::Forbidden.new unless has_legacy_access?(zones, admin_required: admin_required) end - # True if the legacy zone-based permission scheme grants the - # requested level across `zones`. Mirrors the previous behaviour: - # the system must include the org_zone, and `current_user.groups` - # must satisfy `admin?` or `can_manage?`. - private def has_legacy_access?(zones : Array(String), admin_required : Bool = false) : Bool - authority = current_authority.as(::PlaceOS::Model::Authority) - org_zone_id = authority.config["org_zone"]?.try(&.as_s?) - return false unless org_zone_id - return false unless zones.includes?(org_zone_id) - access = check_access(current_user.groups, zones) - admin_required ? access.admin? : access.can_manage? - end - - # Permissions bit corresponding to the current HTTP verb. - private def verb_permission : ::PlaceOS::Model::Permissions - case request.method.upcase - when "POST" then ::PlaceOS::Model::Permissions::Create - when "PUT", "PATCH" then ::PlaceOS::Model::Permissions::Update - when "DELETE" then ::PlaceOS::Model::Permissions::Delete - else ::PlaceOS::Model::Permissions::None - end - end - - # True if the user's effective permissions on `zones` (within any - # of `subsystems`) include `required`, or Manage (which is a - # superset). The resolver already ANDs the user's group perms with - # the GroupZone's perms, so a non-zero result means both sides - # agree. - private def subsystem_grants_on_zones?( - subsystems : Array(String), - zones : Array(String), - required : ::PlaceOS::Model::Permissions, - ) : Bool - return false if zones.empty? - authority_id = current_user.authority_id.as(String) - user_id = current_user.id.as(String) - subsystems.any? do |subsystem| - perms = ::PlaceOS::Model::Group.effective_permissions(authority_id, subsystem, user_id, zones) - perms.manage? || (perms & required) != ::PlaceOS::Model::Permissions::None - end - end - - private def support_subsystem_grants?( - zones : Array(String), - required : ::PlaceOS::Model::Permissions, - ) : Bool - subsystem_grants_on_zones?(["support"], zones, required) - end - # Response helpers ############################################################################################### diff --git a/src/placeos-rest-api/controllers/uploads.cr b/src/placeos-rest-api/controllers/uploads.cr index 0ab118ae..787362b0 100644 --- a/src/placeos-rest-api/controllers/uploads.cr +++ b/src/placeos-rest-api/controllers/uploads.cr @@ -7,6 +7,9 @@ require "./application" module PlaceOS::Api class Uploads < Application + include Utils::Permissions + include Utils::GroupPermissions + base "/api/engine/v2/uploads" @[AC::Route::Filter(:before_action)] @@ -51,6 +54,16 @@ module PlaceOS::Api getter! signer : UploadSigner::Storage? getter! current_upload : ::PlaceOS::Model::Upload? + # Mutations on an existing upload: the owner may always proceed; otherwise + # a support/admin user, or a "support" subsystem user with the verb's bit + # on the org zone, may manage it. (`create` is always self-owned.) + @[AC::Route::Filter(:before_action, only: [:edit, :update, :finished, :destroy])] + def confirm_upload_access + return if current_upload.uploaded_by == current_user.id + required = request.method.upcase == "DELETE" ? ::PlaceOS::Model::Permissions::Delete : ::PlaceOS::Model::Permissions::Update + ensure_support_access!([support_org_zone_id].compact, required) + end + # allow A–Z a–z 0–9 and .!#$%&'*+-/=?^_`{|}~@ TAG_ALLOW_REGEX = /^[A-Za-z0-9.!#$%&'*+\-\/=?\^_`{|}~@]+$/ diff --git a/src/placeos-rest-api/controllers/users.cr b/src/placeos-rest-api/controllers/users.cr index eea39e6b..46d3c694 100644 --- a/src/placeos-rest-api/controllers/users.cr +++ b/src/placeos-rest-api/controllers/users.cr @@ -6,6 +6,9 @@ require "./metadata" module PlaceOS::Api class Users < Application + include Utils::Permissions + include Utils::GroupPermissions + base "/api/engine/v2/users/" # Scopes @@ -14,7 +17,17 @@ module PlaceOS::Api before_action :can_read, only: [:index, :show] before_action :can_write, only: [:create, :update, :destroy, :remove, :revive] - before_action :check_admin, only: [:destroy, :create, :revive, :delete_resource_token, :user_resource_token] + # Resource tokens stay strictly admin-only. + before_action :check_admin, only: [:delete_resource_token, :user_resource_token] + + # User CRUD: admin, or a "support" subsystem user with the verb's bit on + # the org zone (mirrors short_url). `find_user`/`create` keep everything + # scoped to the caller's own authority. + @[AC::Route::Filter(:before_action, only: [:create, :destroy, :revive])] + def check_user_management + return if user_admin? + ensure_support_access!([support_org_zone_id].compact, verb_permission) + end ############################################################################################### @@ -60,8 +73,12 @@ module PlaceOS::Api # Check the user has access to the model @[AC::Route::Filter(:before_action, only: [:update])] protected def check_authorization - # Does the current user have permission to perform the current action - raise Error::Forbidden.new unless user.id == current_user.id || user_admin? + # Self-service and admins always allowed. + return if user.id == current_user.id || user_admin? + # Otherwise a "support" subsystem user may manage users within their + # own authority (Update on the org zone). + raise Error::Forbidden.new unless user.authority_id == current_user.authority_id + ensure_support_access!([support_org_zone_id].compact, ::PlaceOS::Model::Permissions::Update) end ############################################################################################### diff --git a/src/placeos-rest-api/controllers/zones.cr b/src/placeos-rest-api/controllers/zones.cr index fd59a613..3805292b 100644 --- a/src/placeos-rest-api/controllers/zones.cr +++ b/src/placeos-rest-api/controllers/zones.cr @@ -57,7 +57,7 @@ module PlaceOS::Api # "support" subsystem: needs Delete (or Manage) reach on the # parent zone — root zones (no parent) stay admin-only. parent_id = current_zone.parent_id - return if parent_id && support_subsystem_can_modify_zone?(current_user, parent_id) + return if parent_id && support_subsystem_grants?([parent_id], verb_permission) raise Error::Forbidden.new end @@ -77,7 +77,7 @@ module PlaceOS::Api # Support subsystem: per-zone — needs Update (or Manage) reach on # the zone itself. - return if support_subsystem_can_modify_zone?(current_user, current_zone.id.as(String)) + return if support_subsystem_grants?([current_zone.id.as(String)], verb_permission) raise Error::Forbidden.new end @@ -89,7 +89,7 @@ module PlaceOS::Api # "support" subsystem: needs Create (or Manage) reach on the # intended parent. Root creation stays admin-only. parent_id = zone_update.parent_id.presence - return if parent_id && support_subsystem_can_modify_zone?(current_user, parent_id) + return if parent_id && support_subsystem_grants?([parent_id], verb_permission) raise Error::Forbidden.new end @@ -123,26 +123,6 @@ module PlaceOS::Api false end - # True if the user's effective permissions within the "support" - # subsystem on `zone_id` include the bit corresponding to the - # current HTTP verb (POST→Create, PATCH/PUT→Update, DELETE→Delete). - # Manage is a superset and grants every verb. The resolver already - # ANDs the user's group perms with the GroupZone's perms, so a - # non-zero bit means both sides agree. - private def support_subsystem_can_modify_zone?(user : ::PlaceOS::Model::User, zone_id : String) : Bool - authority_id = user.authority_id.as(String) - perms = ::PlaceOS::Model::Group.effective_permissions( - authority_id, "support", user.id.as(String), zone_id, - ) - return true if perms.manage? - case request.method.upcase - when "POST" then perms.create? - when "PUT", "PATCH" then perms.update? - when "DELETE" then perms.delete? - else false - end - end - ############################################################################################### # list the configured zones diff --git a/src/placeos-rest-api/utilities/group_permissions.cr b/src/placeos-rest-api/utilities/group_permissions.cr index f3f64b63..32191c81 100644 --- a/src/placeos-rest-api/utilities/group_permissions.cr +++ b/src/placeos-rest-api/utilities/group_permissions.cr @@ -2,6 +2,8 @@ require "placeos-models/group" require "placeos-models/group/user" require "placeos-models/group/zone" require "placeos-models/permissions" +require "placeos-models/control_system" +require "placeos-models/module" module PlaceOS::Api # Helpers for authorising actions against the group-permissions system. @@ -13,9 +15,21 @@ module PlaceOS::Api # GroupUser entry wins). A Manage grant on a group implicitly covers # that group's descendants. module Utils::GroupPermissions + # Per-request memo caches. Controllers are per-request `ActionController::Base` + # instances, so these are request-scoped and cannot serve stale authz (gates + # run in before_actions, before any mutation). Never cache across requests. + @group_memberships_cache : Hash(String, Hash(UUID, ::PlaceOS::Model::Permissions))? + @subsystem_perms_cache : Hash(String, Hash(String, ::PlaceOS::Model::Permissions))? + # Effective per-group Permissions for the user, keyed by group id. # Groups where the user has no transitive membership are absent. + # Memoised per request (called repeatedly across gates/reads). def group_memberships(user : ::PlaceOS::Model::User) : Hash(UUID, ::PlaceOS::Model::Permissions) + cache = (@group_memberships_cache ||= {} of String => Hash(UUID, ::PlaceOS::Model::Permissions)) + cache[user.id.as(String)] ||= compute_group_memberships(user) + end + + private def compute_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. @@ -150,5 +164,136 @@ module PlaceOS::Api end end end + + # ------------------------------------------------------------------------ + # Support-subsystem authorisation + # + # Shared by every controller that gates on the zone-scoped "support" + # subsystem (systems, zones, modules, settings, metadata, assets, …). + # The host controller must also `include Utils::Permissions` (for the + # legacy `check_access` path); `current_authority`, `current_user`, + # `user_support?`/`user_admin?` and `request` come from `Application`. + # ------------------------------------------------------------------------ + + SUPPORT_SUBSYSTEM = "support" + + # Permissions bit corresponding to the current HTTP verb. + def verb_permission : ::PlaceOS::Model::Permissions + case request.method.upcase + when "POST" then ::PlaceOS::Model::Permissions::Create + when "PUT", "PATCH" then ::PlaceOS::Model::Permissions::Update + when "DELETE" then ::PlaceOS::Model::Permissions::Delete + else ::PlaceOS::Model::Permissions::None + end + end + + # The authority's configured "org zone" (a soft key in `authority.config`). + # Named with a `support_` prefix to avoid clashing with controllers that + # define their own `org_zone_id` (e.g. `modules.cr`). + def support_org_zone_id : String? + current_authority.as(::PlaceOS::Model::Authority).config["org_zone"]?.try(&.as_s?) + end + + # True if the user's effective permissions on `zones` (within any of + # `subsystems`) include `required`, or Manage (a superset). The model + # resolver already ANDs the user's group perms with the GroupZone + # grants, so a non-zero result means both sides agree. + # Memoised `{zone_id => Permissions}` map for the current user within + # `subsystem`, resolved once per request (then reused by every gate / + # scoping query for that subsystem). + def subsystem_zone_permissions(subsystem : String) : Hash(String, ::PlaceOS::Model::Permissions) + cache = (@subsystem_perms_cache ||= {} of String => Hash(String, ::PlaceOS::Model::Permissions)) + cache[subsystem] ||= ::PlaceOS::Model::Group.resolve_subsystem_permissions( + current_user.authority_id.as(String), subsystem, current_user.id.as(String), + ) + end + + def subsystem_grants_on_zones?( + subsystems : Array(String), + zones : Array(String), + required : ::PlaceOS::Model::Permissions, + ) : Bool + return false if zones.empty? + subsystems.any? do |subsystem| + perms_map = subsystem_zone_permissions(subsystem) + perms = zones.reduce(::PlaceOS::Model::Permissions::None) { |acc, z| acc | (perms_map[z]? || ::PlaceOS::Model::Permissions::None) } + perms.manage? || (perms & required) != ::PlaceOS::Model::Permissions::None + end + end + + # Convenience wrapper for the "support" subsystem. + def support_subsystem_grants?(zones : Array(String), required : ::PlaceOS::Model::Permissions) : Bool + subsystem_grants_on_zones?([SUPPORT_SUBSYSTEM], zones, required) + end + + # Legacy org_zone permission path (backwards compatibility): the + # request's `zones` must include the org_zone, and `current_user.groups` + # must satisfy `admin?` (when `admin_required`) or `can_manage?`. + def has_legacy_access?(zones : Array(String), admin_required : Bool = false) : Bool + org_zone = support_org_zone_id + return false unless org_zone + return false unless zones.includes?(org_zone) + access = check_access(current_user.groups, zones) + admin_required ? access.admin? : access.can_manage? + end + + # The combined support gate. Raises `Error::Forbidden` unless one of: + # - the JWT role bypasses (admin when `admin_required`, else support), + # - the legacy org_zone path grants access, or + # - the "support" subsystem grants `required` on `zones`. + def ensure_support_access!( + zones : Array(String), + required : ::PlaceOS::Model::Permissions = verb_permission, + admin_required : Bool = false, + ) : Nil + return if admin_required ? user_admin? : user_support? + return if has_legacy_access?(zones, admin_required) + return if support_subsystem_grants?(zones, required) + raise Error::Forbidden.new + end + + # Resolve a legacy prefixed parent id to the zone ids it implies: + # zone-… -> the zone itself + # sys-… -> the control system's zones + # mod-… -> the module's zones (via its systems) + # driver-… / user-… / other -> [] (no zone scope => deny by default) + def support_zones_for_parent(parent_id : String?) : Array(String) + return [] of String unless parent_id + case + when parent_id.starts_with?("zone-") + [parent_id] + when parent_id.starts_with?("sys-") + ::PlaceOS::Model::ControlSystem.find?(parent_id).try(&.zones) || [] of String + when parent_id.starts_with?("mod-") + module_zones(parent_id) + else + [] of String + end + end + + # Zones a module belongs to: the union of its logic-module system + # (`control_system_id`) and every system that references it. Empty when + # the module is attached to no system (=> deny / admin-or-support only). + def module_zones(module_id : String) : Array(String) + mod = ::PlaceOS::Model::Module.find?(module_id) + return [] of String unless mod + + zones = [] of String + if (sys_id = mod.control_system_id) && (sys = ::PlaceOS::Model::ControlSystem.find?(sys_id)) + zones.concat(sys.zones) + end + ::PlaceOS::Model::ControlSystem.by_module_id(mod.id.as(String)).each do |cs| + zones.concat(cs.zones) + end + zones.uniq + end + + # Zone ids reachable by the current user via the "support" subsystem. + # Useful for scoping index/list queries. Served from the per-request memo. + def support_accessible_zone_ids : Array(String) + subsystem_zone_permissions(SUPPORT_SUBSYSTEM).compact_map do |zid, perms| + zid if perms != ::PlaceOS::Model::Permissions::None + end + end end end