diff --git a/OPENAPI_DOC.yml b/OPENAPI_DOC.yml index 119215ed..c6cb61c3 100644 --- a/OPENAPI_DOC.yml +++ b/OPENAPI_DOC.yml @@ -9044,6 +9044,15 @@ paths: - Domains operationId: PlaceOS::Api::Domains_index parameters: + - name: organisation_id + in: query + description: only rows owned by this organisation (must be within reach) + example: 0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f + schema: + anyOf: + - type: string + format: uuid + - type: "null" - name: q in: query description: 'filters results by the given text: a record matches when any @@ -9169,7 +9178,11 @@ paths: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' post: - summary: add a new domain + summary: add a new domain. Organisation admins create domains in their own + description: 'add a new domain. Organisation admins create domains in their + own + + organisation; cluster admins name the organisation.' tags: - Domains operationId: PlaceOS::Api::Domains_create @@ -9179,7 +9192,16 @@ paths: schema: $ref: '#/components/schemas/PlaceOS.Model.Authority' required: true - parameters: [] + parameters: + - name: organisation_id + in: query + description: the organisation that owns the new domain + example: 0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f + schema: + anyOf: + - type: string + format: uuid + - type: "null" responses: "201": description: Created @@ -9482,6 +9504,15 @@ paths: required: true schema: type: string + - name: organisation_id + in: query + description: move the domain to this organisation (cluster admins only) + example: 0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f + schema: + anyOf: + - type: string + format: uuid + - type: "null" responses: "200": description: OK @@ -9676,6 +9707,15 @@ paths: required: true schema: type: string + - name: organisation_id + in: query + description: move the domain to this organisation (cluster admins only) + example: 0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f + schema: + anyOf: + - type: string + format: uuid + - type: "null" responses: "200": description: OK @@ -11598,6 +11638,15 @@ paths: anyOf: - type: boolean - type: "null" + - name: organisation_id + in: query + description: only rows owned by this organisation (must be within reach) + example: 0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f + schema: + anyOf: + - type: string + format: uuid + - type: "null" - name: q in: query description: 'filters results by the given text: a record matches when any @@ -11742,6 +11791,16 @@ paths: anyOf: - type: "null" - type: string + - name: organisation_id + in: query + description: the organisation that owns a module not bound to a system (cluster + admins only) + example: 0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f + schema: + anyOf: + - type: string + format: uuid + - type: "null" responses: "201": description: Created @@ -13477,6 +13536,15 @@ paths: anyOf: - type: boolean - type: "null" + - name: organisation_id + in: query + description: only rows owned by this organisation (must be within reach) + example: 0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f + schema: + anyOf: + - type: string + format: uuid + - type: "null" - name: q in: query description: 'filters results by the given text: a record matches when any @@ -13602,7 +13670,11 @@ paths: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' post: - summary: adds a new system + summary: adds a new system. The system belongs to its zones' organisation, or + description: 'adds a new system. The system belongs to its zones'' organisation, + or + + the caller''s when the zones are unowned.' tags: - Systems operationId: PlaceOS::Api::Systems_create @@ -15790,6 +15862,15 @@ paths: - Edges operationId: PlaceOS::Api::Edges_index parameters: + - name: organisation_id + in: query + description: only rows owned by this organisation (must be within reach) + example: 0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f + schema: + anyOf: + - type: string + format: uuid + - type: "null" - name: q in: query description: 'filters results by the given text: a record matches when any @@ -17864,54 +17945,63 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/groups: - summary: Groups of users, used to grant permissions to zones and features - get: - summary: List groups. sys_admin sees all within the current authority; other - description: 'List groups. sys_admin sees all within the current authority; - other - - users see only groups they''re a member of (direct or transitive). + /api/engine/v2/grants: + summary: 'Grants: explicit, revocable, optionally time-boxed reach for a user + into a' + description: 'Grants: explicit, revocable, optionally time-boxed reach for a user + into a - Optionally filter by `parent_id` for tree queries (pass the - - literal string `root` for root-level groups). Pass `q` for a - - case-insensitive substring search on `name`.' + partner, organisation or domain they do not belong to (PPT-526)' + get: + summary: grants on a scope, or held by a user tags: - - Groups - operationId: PlaceOS::Api::Groups_index + - Grants + operationId: PlaceOS::Api::Grants_index parameters: - - name: parent_id + - name: scope_type in: query - description: filter by parent group id; pass 'root' for root-level groups + description: partner, organisation or authority + example: organisation schema: anyOf: - type: "null" - type: string - - name: q + - name: scope_id in: query - description: case-insensitive substring search on name (SQL ILIKE) + description: the id of the partner, organisation or domain + example: 0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f schema: anyOf: - type: "null" - type: string - - name: include_children_count + - name: user_id in: query - description: include children_count for each group (useful for tree views) - example: true + description: grants held by this user + example: user-1234 schema: - type: boolean + anyOf: + - type: "null" + - type: string + - name: q + in: query + description: 'filters results by the given text: a record matches when any + word matches its searchable fields as a whole word, and the last word also + matches as a prefix (search as you type)' + schema: + type: string - name: limit in: query + description: the maximum number of results to return + example: 10000 schema: type: integer - format: Int32 + format: UInt32 - name: offset in: query + description: the starting offset of the result set, used to implement pagination schema: type: integer - format: Int32 + format: UInt32 - name: ref in: query description: deprecated, ignored — pagination follows the `Link` header's @@ -17937,7 +18027,7 @@ paths: schema: type: array items: - $ref: '#/components/schemas/PlaceOS.Model.Group' + $ref: '#/components/schemas/PlaceOS.Model.Grant' "409": description: Conflict content: @@ -18017,18 +18107,14 @@ paths: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' post: - summary: Create a new group. A root group (parent_id nil) requires - description: 'Create a new group. A root group (parent_id nil) requires - - sys_admin; a child group requires Manage on the parent.' tags: - - Groups - operationId: PlaceOS::Api::Groups_create + - Grants + operationId: PlaceOS::Api::Grants_create requestBody: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.Group' + $ref: '#/components/schemas/PlaceOS.Api.Grants.GrantBody' required: true parameters: [] responses: @@ -18037,7 +18123,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.Group' + $ref: '#/components/schemas/PlaceOS.Model.Grant' "409": description: Conflict content: @@ -18116,13 +18202,17 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/groups/{id}: - summary: Groups of users, used to grant permissions to zones and features + /api/engine/v2/grants/{id}: + summary: 'Grants: explicit, revocable, optionally time-boxed reach for a user + into a' + description: 'Grants: explicit, revocable, optionally time-boxed reach for a user + into a + + partner, organisation or domain they do not belong to (PPT-526)' get: - summary: Show group details. Visible if sys_admin or a member. tags: - - Groups - operationId: PlaceOS::Api::Groups_show + - Grants + operationId: PlaceOS::Api::Grants_show parameters: - name: id in: path @@ -18136,7 +18226,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.Group' + $ref: '#/components/schemas/PlaceOS.Model.Grant' "409": description: Conflict content: @@ -18215,20 +18305,10 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - put: - summary: Update the group (name / description / parent / features). - description: 'Update the group (name / description / parent / features). - - Changing `features` requires Manage on the parent group.' + delete: tags: - - Groups - operationId: PlaceOS::Api::Groups_update - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PlaceOS.Model.Group' - required: true + - Grants + operationId: PlaceOS::Api::Grants_destroy parameters: - name: id in: path @@ -18236,13 +18316,161 @@ paths: schema: type: string format: uuid + responses: + "202": + description: Accepted + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/groups: + summary: Groups of users, used to grant permissions to zones and features + get: + summary: List groups. sys_admin sees all within the current authority; other + description: 'List groups. sys_admin sees all within the current authority; + other + + users see only groups they''re a member of (direct or transitive). + + Optionally filter by `parent_id` for tree queries (pass the + + literal string `root` for root-level groups). Pass `q` for a + + case-insensitive substring search on `name`.' + tags: + - Groups + operationId: PlaceOS::Api::Groups_index + parameters: + - name: parent_id + in: query + description: filter by parent group id; pass 'root' for root-level groups + schema: + anyOf: + - type: "null" + - type: string + - name: q + in: query + description: case-insensitive substring search on name (SQL ILIKE) + schema: + anyOf: + - type: "null" + - type: string + - name: include_children_count + in: query + description: include children_count for each group (useful for tree views) + example: true + schema: + type: boolean + - name: limit + in: query + schema: + type: integer + format: Int32 + - name: offset + in: query + schema: + type: integer + format: Int32 + - name: ref + in: query + description: deprecated, ignored — pagination follows the `Link` header's + offset + schema: + anyOf: + - type: "null" + - type: string + - name: fields + in: query + description: deprecated, ignored — search covers the resource's indexed fields + style: form + explode: false + schema: + type: array + items: + type: string responses: "200": description: OK content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.Group' + type: array + items: + $ref: '#/components/schemas/PlaceOS.Model.Group' "409": description: Conflict content: @@ -18321,11 +18549,113 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - delete: - summary: Destroy a group (cascades children and junctions via FK). + post: + summary: Create a new group. A root group (parent_id nil) requires + description: 'Create a new group. A root group (parent_id nil) requires + + sys_admin; a child group requires Manage on the parent.' tags: - Groups - operationId: PlaceOS::Api::Groups_destroy + operationId: PlaceOS::Api::Groups_create + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Model.Group' + required: true + parameters: [] + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Model.Group' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/groups/{id}: + summary: Groups of users, used to grant permissions to zones and features + get: + summary: Show group details. Visible if sys_admin or a member. + tags: + - Groups + operationId: PlaceOS::Api::Groups_show parameters: - name: id in: path @@ -18334,8 +18664,12 @@ paths: type: string format: uuid responses: - "202": - description: Accepted + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Model.Group' "409": description: Conflict content: @@ -18414,14 +18748,14 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - patch: + put: summary: Update the group (name / description / parent / features). description: 'Update the group (name / description / parent / features). Changing `features` requires Manage on the parent group.' tags: - Groups - operationId: PlaceOS::Api::Groups_update_2 + operationId: PlaceOS::Api::Groups_update requestBody: content: application/json: @@ -18520,18 +18854,11 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/groups/{id}/features: - summary: Groups of users, used to grant permissions to zones and features - get: - summary: Effective feature flags for this group. - description: 'Effective feature flags for this group. - - The group''s own `features` merged over every ancestor''s, per subsystem, - - with the deepest group winning each key. Shape: `{subsystem => {key => value}}`.' + delete: + summary: Destroy a group (cascades children and junctions via FK). tags: - Groups - operationId: PlaceOS::Api::Groups_features + operationId: PlaceOS::Api::Groups_destroy parameters: - name: id in: path @@ -18539,21 +18866,9 @@ paths: schema: type: string format: uuid - - name: subsystem - in: query - description: only return this subsystem's features - example: signage - schema: - anyOf: - - type: "null" - - type: string responses: - "200": - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/Hash-oString-nHash-oString-nJSON.Any-c-c' + "202": + description: Accepted "409": description: Conflict content: @@ -18632,38 +18947,34 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/groups/ad_groups/sync: - summary: Groups of users, used to grant permissions to zones and features - post: - summary: Sync a user's group memberships with their AD groups. - description: 'Sync a user''s group memberships with their AD groups. - - Uses each group''s `ad_group_mappings` to add the user to mapped groups - - and remove memberships that were auto-assigned from AD groups the user - - has left; manually added memberships are untouched. Returns the user''s + patch: + summary: Update the group (name / description / parent / features). + description: 'Update the group (name / description / parent / features). - group memberships in the current authority. Requires support or sys_admin.' + Changing `features` requires Manage on the parent group.' tags: - Groups - operationId: PlaceOS::Api::Groups_sync_ad_groups + operationId: PlaceOS::Api::Groups_update_2 requestBody: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Api.Groups.AdGroupSync' + $ref: '#/components/schemas/PlaceOS.Model.Group' required: true - parameters: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid responses: "200": description: OK content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/PlaceOS.Model.GroupUser' + $ref: '#/components/schemas/PlaceOS.Model.Group' "409": description: Conflict content: @@ -18742,16 +19053,28 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/groups/current: + /api/engine/v2/groups/{id}/features: summary: Groups of users, used to grant permissions to zones and features get: + summary: Effective feature flags for this group. + description: 'Effective feature flags for this group. + + The group''s own `features` merged over every ancestor''s, per subsystem, + + with the deepest group winning each key. Shape: `{subsystem => {key => value}}`.' tags: - Groups - operationId: PlaceOS::Api::Groups_current + operationId: PlaceOS::Api::Groups_features parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid - name: subsystem in: query - description: filter to groups participating in this subsystem (e.g. 'signage') + description: only return this subsystem's features example: signage schema: anyOf: @@ -18763,9 +19086,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/PlaceOS.Api.Groups.CurrentGroup' + $ref: '#/components/schemas/Hash-oString-nHash-oString-nJSON.Any-c-c' "409": description: Conflict content: @@ -18844,63 +19165,29 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/group_history: - summary: Audit history of changes to groups and their memberships - get: - summary: List audit entries, newest first. sys_admin sees everything; other - description: 'List audit entries, newest first. sys_admin sees everything; other + /api/engine/v2/groups/ad_groups/sync: + summary: Groups of users, used to grant permissions to zones and features + post: + summary: Sync a user's group memberships with their AD groups. + description: 'Sync a user''s group memberships with their AD groups. - callers must pass `group_id=` and have Manage on it.' + Uses each group''s `ad_group_mappings` to add the user to mapped groups + + and remove memberships that were auto-assigned from AD groups the user + + has left; manually added memberships are untouched. Returns the user''s + + group memberships in the current authority. Requires support or sys_admin.' tags: - - History - operationId: PlaceOS::Api::Groups::History_index - parameters: - - name: group_id - in: query - description: only return changes to this group and its memberships - example: 0192d1c8-2f0e-7cc1-9d2a-6f1c7e1b3a51 - schema: - anyOf: - - type: string - format: uuid - - type: "null" - - name: limit - in: query - description: the maximum number of results to return - example: 100 - schema: - type: integer - format: Int32 - - name: offset - in: query - description: the starting offset of the result set, used to implement pagination - schema: - type: integer - format: Int32 - - name: q - in: query - description: 'filters results by the given text: a record matches when any - word matches its searchable fields as a whole word, and the last word also - matches as a prefix (search as you type)' - schema: - type: string - - name: ref - in: query - description: deprecated, ignored — pagination follows the `Link` header's - offset - schema: - anyOf: - - type: "null" - - type: string - - name: fields - in: query - description: deprecated, ignored — search covers the resource's indexed fields - style: form - explode: false - schema: - type: array - items: - type: string + - Groups + operationId: PlaceOS::Api::Groups_sync_ad_groups + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Groups.AdGroupSync' + required: true + parameters: [] responses: "200": description: OK @@ -18909,7 +19196,7 @@ paths: schema: type: array items: - $ref: '#/components/schemas/PlaceOS.Model.History' + $ref: '#/components/schemas/PlaceOS.Model.GroupUser' "409": description: Conflict content: @@ -18988,26 +19275,30 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/group_history/{id}: - summary: Audit history of changes to groups and their memberships + /api/engine/v2/groups/current: + summary: Groups of users, used to grant permissions to zones and features get: tags: - - History - operationId: PlaceOS::Api::Groups::History_show + - Groups + operationId: PlaceOS::Api::Groups_current parameters: - - name: id - in: path - required: true + - name: subsystem + in: query + description: filter to groups participating in this subsystem (e.g. 'signage') + example: signage schema: - type: string - format: uuid + anyOf: + - type: "null" + - type: string responses: "200": description: OK content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.History' + type: array + items: + $ref: '#/components/schemas/PlaceOS.Api.Groups.CurrentGroup' "409": description: Conflict content: @@ -19086,19 +19377,21 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/group_invitations: - summary: Invitations for users to join groups + /api/engine/v2/group_history: + summary: Audit history of changes to groups and their memberships get: - summary: List invitations. Optionally filter by `group_id`. Non-admin - description: 'List invitations. Optionally filter by `group_id`. Non-admin + summary: List audit entries, newest first. sys_admin sees everything; other + description: 'List audit entries, newest first. sys_admin sees everything; other - callers must have Manage on the target group.' + callers must pass `group_id=` and have Manage on it.' tags: - - Invitations - operationId: PlaceOS::Api::Groups::Invitations_index + - History + operationId: PlaceOS::Api::Groups::History_index parameters: - name: group_id in: query + description: only return changes to this group and its memberships + example: 0192d1c8-2f0e-7cc1-9d2a-6f1c7e1b3a51 schema: anyOf: - type: string @@ -19106,11 +19399,14 @@ paths: - type: "null" - name: limit in: query + description: the maximum number of results to return + example: 100 schema: type: integer format: Int32 - name: offset in: query + description: the starting offset of the result set, used to implement pagination schema: type: integer format: Int32 @@ -19146,7 +19442,7 @@ paths: schema: type: array items: - $ref: '#/components/schemas/PlaceOS.Model.GroupInvitation' + $ref: '#/components/schemas/PlaceOS.Model.History' "409": description: Conflict content: @@ -19225,30 +19521,26 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - post: - summary: Custom body-driven create so we can return the plaintext secret - description: 'Custom body-driven create so we can return the plaintext secret - - alongside the invitation. Authorisation check is inline here (not - - a before_action) so the body is consumed exactly once.' + /api/engine/v2/group_history/{id}: + summary: Audit history of changes to groups and their memberships + get: tags: - - Invitations - operationId: PlaceOS::Api::Groups::Invitations_create - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PlaceOS.Api.Groups.Invitations.InvitationCreatePayload' + - History + operationId: PlaceOS::Api::Groups::History_show + parameters: + - name: id + in: path required: true - parameters: [] + schema: + type: string + format: uuid responses: - "201": - description: Created + "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Api.Groups.Invitations.InvitationCreatedResponse' + $ref: '#/components/schemas/PlaceOS.Model.History' "409": description: Conflict content: @@ -19327,26 +19619,67 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/group_invitations/{id}: + /api/engine/v2/group_invitations: summary: Invitations for users to join groups get: + summary: List invitations. Optionally filter by `group_id`. Non-admin + description: 'List invitations. Optionally filter by `group_id`. Non-admin + + callers must have Manage on the target group.' tags: - Invitations - operationId: PlaceOS::Api::Groups::Invitations_show + operationId: PlaceOS::Api::Groups::Invitations_index parameters: - - name: id - in: path - required: true + - name: group_id + in: query + schema: + anyOf: + - type: string + format: uuid + - type: "null" + - name: limit + in: query + schema: + type: integer + format: Int32 + - name: offset + in: query + schema: + type: integer + format: Int32 + - name: q + in: query + description: 'filters results by the given text: a record matches when any + word matches its searchable fields as a whole word, and the last word also + matches as a prefix (search as you type)' schema: type: string - format: uuid + - name: ref + in: query + description: deprecated, ignored — pagination follows the `Link` header's + offset + schema: + anyOf: + - type: "null" + - type: string + - name: fields + in: query + description: deprecated, ignored — search covers the resource's indexed fields + style: form + explode: false + schema: + type: array + items: + type: string responses: "200": description: OK content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.GroupInvitation' + type: array + items: + $ref: '#/components/schemas/PlaceOS.Model.GroupInvitation' "409": description: Conflict content: @@ -19425,20 +19758,30 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - delete: + post: + summary: Custom body-driven create so we can return the plaintext secret + description: 'Custom body-driven create so we can return the plaintext secret + + alongside the invitation. Authorisation check is inline here (not + + a before_action) so the body is consumed exactly once.' tags: - Invitations - operationId: PlaceOS::Api::Groups::Invitations_destroy - parameters: - - name: id - in: path + operationId: PlaceOS::Api::Groups::Invitations_create + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Groups.Invitations.InvitationCreatePayload' required: true - schema: - type: string - format: uuid + parameters: [] responses: - "202": - description: Accepted + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Groups.Invitations.InvitationCreatedResponse' "409": description: Conflict content: @@ -19517,16 +19860,12 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/group_invitations/{id}/accept: + /api/engine/v2/group_invitations/{id}: summary: Invitations for users to join groups - post: - summary: Accept the invitation, materialising it into a GroupUser for the - description: 'Accept the invitation, materialising it into a GroupUser for the - - current user. The invitation row is destroyed on success.' + get: tags: - Invitations - operationId: PlaceOS::Api::Groups::Invitations_accept + operationId: PlaceOS::Api::Groups::Invitations_show parameters: - name: id in: path @@ -19540,7 +19879,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.GroupUser' + $ref: '#/components/schemas/PlaceOS.Model.GroupInvitation' "409": description: Conflict content: @@ -19619,75 +19958,20 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/group_users: - summary: Group memberships, the users in each group and their permissions - get: - summary: GroupUser rows. Optional `group_id` scopes to one group; - description: 'GroupUser rows. Optional `group_id` scopes to one group; - - `user_id=me` returns the current user''s own rows. Non-admin - - callers without Manage on the specified group see only their own.' + delete: tags: - - Users - operationId: PlaceOS::Api::Groups::Users_index + - Invitations + operationId: PlaceOS::Api::Groups::Invitations_destroy parameters: - - name: group_id - in: query - schema: - anyOf: - - type: string - format: uuid - - type: "null" - - name: user_id - in: query - schema: - anyOf: - - type: "null" - - type: string - - name: limit - in: query - schema: - type: integer - format: Int32 - - name: offset - in: query - schema: - type: integer - format: Int32 - - name: q - in: query - description: 'filters results by the given text: a record matches when any - word matches its searchable fields as a whole word, and the last word also - matches as a prefix (search as you type)' + - name: id + in: path + required: true schema: type: string - - name: ref - in: query - description: deprecated, ignored — pagination follows the `Link` header's - offset - schema: - anyOf: - - type: "null" - - type: string - - name: fields - in: query - description: deprecated, ignored — search covers the resource's indexed fields - style: form - explode: false - schema: - type: array - items: - type: string + format: uuid responses: - "200": - description: OK - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/PlaceOS.Model.GroupUser' + "202": + description: Accepted "409": description: Conflict content: @@ -19766,20 +20050,26 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/group_invitations/{id}/accept: + summary: Invitations for users to join groups post: + summary: Accept the invitation, materialising it into a GroupUser for the + description: 'Accept the invitation, materialising it into a GroupUser for the + + current user. The invitation row is destroyed on success.' tags: - - Users - operationId: PlaceOS::Api::Groups::Users_create - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PlaceOS.Model.GroupUser' + - Invitations + operationId: PlaceOS::Api::Groups::Invitations_accept + parameters: + - name: id + in: path required: true - parameters: [] + schema: + type: string + format: uuid responses: - "201": - description: Created + "200": + description: OK content: application/json: schema: @@ -19862,31 +20152,75 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/group_users/{user_id}/{group_id}: + /api/engine/v2/group_users: summary: Group memberships, the users in each group and their permissions get: + summary: GroupUser rows. Optional `group_id` scopes to one group; + description: 'GroupUser rows. Optional `group_id` scopes to one group; + + `user_id=me` returns the current user''s own rows. Non-admin + + callers without Manage on the specified group see only their own.' tags: - Users - operationId: PlaceOS::Api::Groups::Users_show + operationId: PlaceOS::Api::Groups::Users_index parameters: + - name: group_id + in: query + schema: + anyOf: + - type: string + format: uuid + - type: "null" - name: user_id - in: path - required: true + in: query schema: - type: string - - name: group_id - in: path - required: true + anyOf: + - type: "null" + - type: string + - name: limit + in: query + schema: + type: integer + format: Int32 + - name: offset + in: query + schema: + type: integer + format: Int32 + - name: q + in: query + description: 'filters results by the given text: a record matches when any + word matches its searchable fields as a whole word, and the last word also + matches as a prefix (search as you type)' schema: type: string - format: uuid + - name: ref + in: query + description: deprecated, ignored — pagination follows the `Link` header's + offset + schema: + anyOf: + - type: "null" + - type: string + - name: fields + in: query + description: deprecated, ignored — search covers the resource's indexed fields + style: form + explode: false + schema: + type: array + items: + type: string responses: "200": description: OK content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.GroupUser' + type: array + items: + $ref: '#/components/schemas/PlaceOS.Model.GroupUser' "409": description: Conflict content: @@ -19965,31 +20299,20 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - put: + post: tags: - Users - operationId: PlaceOS::Api::Groups::Users_update + operationId: PlaceOS::Api::Groups::Users_create requestBody: content: application/json: schema: $ref: '#/components/schemas/PlaceOS.Model.GroupUser' required: true - parameters: - - name: user_id - in: path - required: true - schema: - type: string - - name: group_id - in: path - required: true - schema: - type: string - format: uuid + parameters: [] responses: - "200": - description: OK + "201": + description: Created content: application/json: schema: @@ -20072,10 +20395,12 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - delete: + /api/engine/v2/group_users/{user_id}/{group_id}: + summary: Group memberships, the users in each group and their permissions + get: tags: - Users - operationId: PlaceOS::Api::Groups::Users_destroy + operationId: PlaceOS::Api::Groups::Users_show parameters: - name: user_id in: path @@ -20089,8 +20414,12 @@ paths: type: string format: uuid responses: - "202": - description: Accepted + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Model.GroupUser' "409": description: Conflict content: @@ -20169,10 +20498,10 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - patch: + put: tags: - Users - operationId: PlaceOS::Api::Groups::Users_update_2 + operationId: PlaceOS::Api::Groups::Users_update requestBody: content: application/json: @@ -20276,70 +20605,25 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/group_zones: - summary: The zones a group has access to, and the permissions it has there - get: - summary: List GroupZone rows. Filterable by group_id / zone_id. + delete: tags: - - Zones - operationId: PlaceOS::Api::Groups::Zones_index + - Users + operationId: PlaceOS::Api::Groups::Users_destroy parameters: - - name: group_id - in: query - schema: - anyOf: - - type: string - format: uuid - - type: "null" - - name: zone_id - in: query - schema: - anyOf: - - type: "null" - - type: string - - name: limit - in: query - schema: - type: integer - format: Int32 - - name: offset - in: query - schema: - type: integer - format: Int32 - - name: q - in: query - description: 'filters results by the given text: a record matches when any - word matches its searchable fields as a whole word, and the last word also - matches as a prefix (search as you type)' + - name: user_id + in: path + required: true schema: type: string - - name: ref - in: query - description: deprecated, ignored — pagination follows the `Link` header's - offset - schema: - anyOf: - - type: "null" - - type: string - - name: fields - in: query - description: deprecated, ignored — search covers the resource's indexed fields - style: form - explode: false + - name: group_id + in: path + required: true schema: - type: array - items: - type: string + type: string + format: uuid responses: - "200": - description: OK - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/PlaceOS.Model.GroupZone' + "202": + description: Accepted "409": description: Conflict content: @@ -20418,24 +20702,35 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - post: + patch: tags: - - Zones - operationId: PlaceOS::Api::Groups::Zones_create + - Users + operationId: PlaceOS::Api::Groups::Users_update_2 requestBody: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.GroupZone' + $ref: '#/components/schemas/PlaceOS.Model.GroupUser' required: true - parameters: [] + parameters: + - name: user_id + in: path + required: true + schema: + type: string + - name: group_id + in: path + required: true + schema: + type: string + format: uuid responses: - "201": - description: Created + "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.GroupZone' + $ref: '#/components/schemas/PlaceOS.Model.GroupUser' "409": description: Conflict content: @@ -20514,31 +20809,269 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/group_zones/{group_id}/{zone_id}: + /api/engine/v2/group_zones: summary: The zones a group has access to, and the permissions it has there get: + summary: List GroupZone rows. Filterable by group_id / zone_id. tags: - Zones - operationId: PlaceOS::Api::Groups::Zones_show + operationId: PlaceOS::Api::Groups::Zones_index parameters: - name: group_id - in: path - required: true + in: query schema: - type: string - format: uuid + anyOf: + - type: string + format: uuid + - type: "null" - name: zone_id - in: path - required: true + in: query + schema: + anyOf: + - type: "null" + - type: string + - name: limit + in: query + schema: + type: integer + format: Int32 + - name: offset + in: query + schema: + type: integer + format: Int32 + - name: q + in: query + description: 'filters results by the given text: a record matches when any + word matches its searchable fields as a whole word, and the last word also + matches as a prefix (search as you type)' schema: type: string + - name: ref + in: query + description: deprecated, ignored — pagination follows the `Link` header's + offset + schema: + anyOf: + - type: "null" + - type: string + - name: fields + in: query + description: deprecated, ignored — search covers the resource's indexed fields + style: form + explode: false + schema: + type: array + items: + type: string responses: "200": description: OK content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.GroupZone' + type: array + items: + $ref: '#/components/schemas/PlaceOS.Model.GroupZone' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + post: + tags: + - Zones + operationId: PlaceOS::Api::Groups::Zones_create + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Model.GroupZone' + required: true + parameters: [] + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Model.GroupZone' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/group_zones/{group_id}/{zone_id}: + summary: The zones a group has access to, and the permissions it has there + get: + tags: + - Zones + operationId: PlaceOS::Api::Groups::Zones_show + parameters: + - name: group_id + in: path + required: true + schema: + type: string + format: uuid + - name: zone_id + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Model.GroupZone' "409": description: Conflict content: @@ -21350,33 +21883,1534 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/maps/ai/capabilities: - summary: AI editing of a level's SVG map, with the signage AI provider rows. - description: 'AI editing of a level''s SVG map, with the signage AI provider rows. - - - The editor posts the instruction and the context it built from the map (the - - element index, the shortlisted subtrees, the annotations and a marked up - - snapshot by upload id). The answer is a list of operations the editor - - applies, reviews and saves itself; nothing here reads or writes the map. + /api/engine/v2/maps/ai/capabilities: + summary: AI editing of a level's SVG map, with the signage AI provider rows. + description: 'AI editing of a level''s SVG map, with the signage AI provider rows. + + + The editor posts the instruction and the context it built from the map (the + + element index, the shortlisted subtrees, the annotations and a marked up + + snapshot by upload id). The answer is a list of operations the editor + + applies, reviews and saves itself; nothing here reads or writes the map. + + One job is one vendor call, long polled through `jobs/:id` as signage is.' + get: + summary: what this domain can do, and what the caller has left of their quota + tags: + - MapAI + operationId: PlaceOS::Api::MapAI_capabilities + parameters: [] + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.MapAI.Capabilities' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/maps/ai/edit: + summary: AI editing of a level's SVG map, with the signage AI provider rows. + description: 'AI editing of a level''s SVG map, with the signage AI provider rows. + + + The editor posts the instruction and the context it built from the map (the + + element index, the shortlisted subtrees, the annotations and a marked up + + snapshot by upload id). The answer is a list of operations the editor + + applies, reviews and saves itself; nothing here reads or writes the map. + + One job is one vendor call, long polled through `jobs/:id` as signage is.' + post: + summary: ask for operations that carry out an instruction on a level's map + tags: + - MapAI + operationId: PlaceOS::Api::MapAI_edit + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.MapAI.EditParams' + required: true + parameters: [] + responses: + "202": + description: Accepted + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.MapAI.JobResponse' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/maps/ai/jobs/{id}: + summary: AI editing of a level's SVG map, with the signage AI provider rows. + description: 'AI editing of a level''s SVG map, with the signage AI provider rows. + + + The editor posts the instruction and the context it built from the map (the + + element index, the shortlisted subtrees, the annotations and a marked up + + snapshot by upload id). The answer is a list of operations the editor + + applies, reviews and saves itself; nothing here reads or writes the map. + + One job is one vendor call, long polled through `jobs/:id` as signage is.' + get: + summary: 'long poll: holds until the job changes past `since`, or `wait` runs + out' + tags: + - MapAI + operationId: PlaceOS::Api::MapAI_show_job + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: wait + in: query + description: seconds to hold the request open, 0 to answer immediately + example: 25 + schema: + type: integer + format: Int32 + - name: since + in: query + description: the version the caller already has + example: 3 + schema: + anyOf: + - type: integer + format: Int32 + - type: "null" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.MapAI.JobResponse' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/maps/ai/jobs: + summary: AI editing of a level's SVG map, with the signage AI provider rows. + description: 'AI editing of a level''s SVG map, with the signage AI provider rows. + + + The editor posts the instruction and the context it built from the map (the + + element index, the shortlisted subtrees, the annotations and a marked up + + snapshot by upload id). The answer is a list of operations the editor + + applies, reviews and saves itself; nothing here reads or writes the map. + + One job is one vendor call, long polled through `jobs/:id` as signage is.' + get: + summary: recent map edit jobs + tags: + - MapAI + operationId: PlaceOS::Api::MapAI_index_jobs + parameters: + - name: mine + in: query + description: only the caller's own jobs + example: true + schema: + type: boolean + - name: zone_id + in: query + description: only jobs against this zone + example: zone-1234 + schema: + anyOf: + - type: "null" + - type: string + - name: kind + in: query + description: map_edit; another kind lists that kind instead + example: map_edit + schema: + type: string + - name: limit + in: query + schema: + type: integer + format: Int32 + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PlaceOS.Api.MapAI.JobResponse' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/maps/ai/jobs/{id}/cancel: + summary: AI editing of a level's SVG map, with the signage AI provider rows. + description: 'AI editing of a level''s SVG map, with the signage AI provider rows. + + + The editor posts the instruction and the context it built from the map (the + + element index, the shortlisted subtrees, the annotations and a marked up + + snapshot by upload id). The answer is a list of operations the editor + + applies, reviews and saves itself; nothing here reads or writes the map. + + One job is one vendor call, long polled through `jobs/:id` as signage is.' + post: + summary: ask a running job to stop. A call already with the vendor runs to completion. + tags: + - MapAI + operationId: PlaceOS::Api::MapAI_cancel + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.MapAI.JobResponse' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/maps/ai/jobs/{id}/resolve: + summary: AI editing of a level's SVG map, with the signage AI provider rows. + description: 'AI editing of a level''s SVG map, with the signage AI provider rows. + + + The editor posts the instruction and the context it built from the map (the + + element index, the shortlisted subtrees, the annotations and a marked up + + snapshot by upload id). The answer is a list of operations the editor + + applies, reviews and saves itself; nothing here reads or writes the map. + + One job is one vendor call, long polled through `jobs/:id` as signage is.' + post: + summary: 'record what the person did with the answer: which ops were applied + or' + description: 'record what the person did with the answer: which ops were applied + or + + rejected and why, and the upload the saved map became' + tags: + - MapAI + operationId: PlaceOS::Api::MapAI_resolve + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.MapAI.ResolveParams' + required: true + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.MapAI.JobResponse' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/maps/ai/usage: + summary: AI editing of a level's SVG map, with the signage AI provider rows. + description: 'AI editing of a level''s SVG map, with the signage AI provider rows. + + + The editor posts the instruction and the context it built from the map (the + + element index, the shortlisted subtrees, the annotations and a marked up + + snapshot by upload id). The answer is a list of operations the editor + + applies, reviews and saves itself; nothing here reads or writes the map. + + One job is one vendor call, long polled through `jobs/:id` as signage is.' + get: + summary: map edit spend per provider and model + tags: + - MapAI + operationId: PlaceOS::Api::MapAI_usage + parameters: + - name: from + in: query + description: unix seconds, defaults to 30 days ago + schema: + anyOf: + - type: integer + format: Int64 + - type: "null" + - name: to + in: query + description: unix seconds, defaults to now + schema: + anyOf: + - type: integer + format: Int64 + - type: "null" + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PlaceOS.Model.SignageAIJob.UsageRow' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/metadata/{name}/bulk: + summary: Metadata, named JSON documents attached to zones, systems and users (e.g. + floor maps, booking rules) + get: + summary: Fetch metadata with a specific name for multiple resources + description: 'Fetch metadata with a specific name for multiple resources + + + Allows bulk retrieval of metadata across multiple parent entities' + tags: + - Metadata + operationId: PlaceOS::Api::Metadata_bulk_fetch + parameters: + - name: name + in: path + description: the name of the metadata key to fetch + example: settings + required: true + schema: + type: string + - name: parent_ids + in: query + description: comma-separated list of parent IDs (zones, systems, users, etc.) + example: + - zone-1 + - zone-2 + - zone-3 + required: true + style: form + explode: false + schema: + type: array + items: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Hash-oString-nPlaceOS.Model.Metadata.Interface-pNil-c' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/metadata/{id}: + summary: Metadata, named JSON documents attached to zones, systems and users (e.g. + floor maps, booking rules) + get: + summary: Fetch metadata for a model + description: 'Fetch metadata for a model + + + Filter for a specific metadata by name via `name` param' + tags: + - Metadata + operationId: PlaceOS::Api::Metadata_show + parameters: + - name: id + in: path + description: the parent id of the metadata to be returned + required: true + schema: + type: string + - name: name + in: query + description: the name of the metadata key + example: config + schema: + anyOf: + - type: "null" + - type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Hash-oString-nPlaceOS.Model.Metadata.Interface-c' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + put: + summary: replace the metadata with this new metadata + description: 'replace the metadata with this new metadata + + udpates are signalled on the `placeos/metadata/changed` channel' + tags: + - Metadata + operationId: PlaceOS::Api::Metadata_update + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Model.Metadata.Interface' + required: true + parameters: + - name: id + in: path + description: the parent id of the metadata to be replaced + required: true + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Model.Metadata.Interface' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + delete: + summary: remove a metadata entry from the database + tags: + - Metadata + operationId: PlaceOS::Api::Metadata_destroy + parameters: + - name: id + in: path + description: the parent id of the metadata to be returned + required: true + schema: + type: string + - name: name + in: query + description: the name of the metadata key + example: config + required: true + schema: + type: string + responses: + "202": + description: Accepted + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + patch: + summary: update only the keys provided on the selected metadata + description: 'update only the keys provided on the selected metadata + + udpates are signalled on the `placeos/metadata/changed` channel' + tags: + - Metadata + operationId: PlaceOS::Api::Metadata_merge + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Model.Metadata.Interface' + required: true + parameters: + - name: id + in: path + description: the parent id of the metadata to be updated + required: true + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Model.Metadata.Interface' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/metadata/{id}/children: + summary: Metadata, named JSON documents attached to zones, systems and users (e.g. + floor maps, booking rules) + get: + summary: Fetch metadata for Zone children + description: 'Fetch metadata for Zone children + + + Filter for a specific metadata by name via `name` param. + + Includes the parent metadata by default via `include_parent` param.' + tags: + - Metadata + operationId: PlaceOS::Api::Metadata_children_metadata + parameters: + - name: id + in: path + description: the parent id of the metadata to be returned + required: true + schema: + type: string + - name: include_parent + in: query + description: the parent metadata is included in the results by default + example: false + schema: + type: boolean + - name: name + in: query + description: filter for a particular metadata key + example: config + schema: + anyOf: + - type: "null" + - type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PlaceOS.Api.Metadata.Children' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/metadata/{id}/name: + summary: Metadata, named JSON documents attached to zones, systems and users (e.g. + floor maps, booking rules) + patch: + summary: rename a metadata field + description: 'rename a metadata field - One job is one vendor call, long polled through `jobs/:id` as signage is.' - get: - summary: what this domain can do, and what the caller has left of their quota + udpates are signalled on the `placeos/metadata/changed` channel' tags: - - MapAI - operationId: PlaceOS::Api::MapAI_capabilities - parameters: [] + - Metadata + operationId: PlaceOS::Api::Metadata_rename + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Metadata.Rename' + required: true + parameters: + - name: id + in: path + description: the parent id of the metadata to be renamed + required: true + schema: + type: string responses: "200": description: OK content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Api.MapAI.Capabilities' + $ref: '#/components/schemas/PlaceOS.Model.Metadata.Interface' "409": description: Conflict content: @@ -21455,39 +23489,49 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/maps/ai/edit: - summary: AI editing of a level's SVG map, with the signage AI provider rows. - description: 'AI editing of a level''s SVG map, with the signage AI provider rows. - - - The editor posts the instruction and the context it built from the map (the - - element index, the shortlisted subtrees, the annotations and a marked up - - snapshot by upload id). The answer is a list of operations the editor - - applies, reviews and saves itself; nothing here reads or writes the map. - - One job is one vendor call, long polled through `jobs/:id` as signage is.' - post: - summary: ask for operations that carry out an instruction on a level's map + /api/engine/v2/metadata/{id}/history: + summary: Metadata, named JSON documents attached to zones, systems and users (e.g. + floor maps, booking rules) + get: + summary: Returns the version history for a Settings model tags: - - MapAI - operationId: PlaceOS::Api::MapAI_edit - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PlaceOS.Api.MapAI.EditParams' + - Metadata + operationId: PlaceOS::Api::Metadata_history + parameters: + - name: id + in: path + description: the parent id of the metadata to be returned required: true - parameters: [] + schema: + type: string + - name: name + in: query + description: the name of the metadata key + example: config + schema: + anyOf: + - type: "null" + - type: string + - name: limit + in: query + description: the maximum number of results to return + example: 10000 + schema: + type: integer + format: Int32 + - name: offset + in: query + description: the starting offset of the result set. Used to implement pagination + schema: + type: integer + format: Int32 responses: - "202": - description: Accepted + "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Api.MapAI.JobResponse' + $ref: '#/components/schemas/Hash-oString-nArray-oPlaceOS.Model.Metadata.Interface-c-c' "409": description: Conflict content: @@ -21566,56 +23610,19 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/maps/ai/jobs/{id}: - summary: AI editing of a level's SVG map, with the signage AI provider rows. - description: 'AI editing of a level''s SVG map, with the signage AI provider rows. - - - The editor posts the instruction and the context it built from the map (the - - element index, the shortlisted subtrees, the annotations and a marked up - - snapshot by upload id). The answer is a list of operations the editor - - applies, reviews and saves itself; nothing here reads or writes the map. + /api/engine/v2/mqtt/user: + post: + summary: 'For MQTT JWT access: https://github.com/iegomez/mosquitto-go-auth#remote-mode' + description: 'For MQTT JWT access: https://github.com/iegomez/mosquitto-go-auth#remote-mode - One job is one vendor call, long polled through `jobs/:id` as signage is.' - get: - summary: 'long poll: holds until the job changes past `since`, or `wait` runs - out' + jwt_response_mode: status, jwt_params_mode: form' tags: - - MapAI - operationId: PlaceOS::Api::MapAI_show_job - parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - - name: wait - in: query - description: seconds to hold the request open, 0 to answer immediately - example: 25 - schema: - type: integer - format: Int32 - - name: since - in: query - description: the version the caller already has - example: 3 - schema: - anyOf: - - type: integer - format: Int32 - - type: "null" + - MQTT + operationId: PlaceOS::Api::MQTT_mqtt_user + parameters: [] responses: "200": description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/PlaceOS.Api.MapAI.JobResponse' "409": description: Conflict content: @@ -21694,60 +23701,38 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/maps/ai/jobs: - summary: AI editing of a level's SVG map, with the signage AI provider rows. - description: 'AI editing of a level''s SVG map, with the signage AI provider rows. - - - The editor posts the instruction and the context it built from the map (the - - element index, the shortlisted subtrees, the annotations and a marked up - - snapshot by upload id). The answer is a list of operations the editor - - applies, reviews and saves itself; nothing here reads or writes the map. + /api/engine/v2/mqtt/access: + post: + summary: 'Sends a form with the following params: topic, clientid, acc (1: read, + 2: write, 3: readwrite, 4: subscribe)' + description: 'Sends a form with the following params: topic, clientid, acc (1: + read, 2: write, 3: readwrite, 4: subscribe) - One job is one vendor call, long polled through `jobs/:id` as signage is.' - get: - summary: recent map edit jobs + example payload: acc=4&clientid=clientId-NwsUNfV30&topic=%2A' tags: - - MapAI - operationId: PlaceOS::Api::MapAI_index_jobs + - MQTT + operationId: PlaceOS::Api::MQTT_mqtt_access parameters: - - name: mine + - name: topic in: query - description: only the caller's own jobs - example: true + required: true schema: - type: boolean - - name: zone_id + type: string + - name: acc + in: query + description: access level required + required: true + schema: + $ref: '#/components/schemas/PlaceOS.Api.MQTT.MqttAcl' + - name: clientid in: query - description: only jobs against this zone - example: zone-1234 schema: anyOf: - type: "null" - type: string - - name: kind - in: query - description: map_edit; another kind lists that kind instead - example: map_edit - schema: - type: string - - name: limit - in: query - schema: - type: integer - format: Int32 responses: "200": description: OK - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/PlaceOS.Api.MapAI.JobResponse' "409": description: Conflict content: @@ -21826,39 +23811,15 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/maps/ai/jobs/{id}/cancel: - summary: AI editing of a level's SVG map, with the signage AI provider rows. - description: 'AI editing of a level''s SVG map, with the signage AI provider rows. - - - The editor posts the instruction and the context it built from the map (the - - element index, the shortlisted subtrees, the annotations and a marked up - - snapshot by upload id). The answer is a list of operations the editor - - applies, reviews and saves itself; nothing here reads or writes the map. - - One job is one vendor call, long polled through `jobs/:id` as signage is.' + /api/engine/v2/notifications/google: post: - summary: ask a running job to stop. A call already with the vendor runs to completion. tags: - - MapAI - operationId: PlaceOS::Api::MapAI_cancel - parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid + - PushNotifications + operationId: PlaceOS::Api::PushNotifications_google + parameters: [] responses: "200": description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/PlaceOS.Api.MapAI.JobResponse' "409": description: Conflict content: @@ -21937,50 +23898,21 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/maps/ai/jobs/{id}/resolve: - summary: AI editing of a level's SVG map, with the signage AI provider rows. - description: 'AI editing of a level''s SVG map, with the signage AI provider rows. - - - The editor posts the instruction and the context it built from the map (the - - element index, the shortlisted subtrees, the annotations and a marked up - - snapshot by upload id). The answer is a list of operations the editor - - applies, reviews and saves itself; nothing here reads or writes the map. - - One job is one vendor call, long polled through `jobs/:id` as signage is.' + /api/engine/v2/notifications/office365: post: - summary: 'record what the person did with the answer: which ops were applied - or' - description: 'record what the person did with the answer: which ops were applied - or - - rejected and why, and the upload the saved map became' tags: - - MapAI - operationId: PlaceOS::Api::MapAI_resolve + - PushNotifications + operationId: PlaceOS::Api::PushNotifications_microsoft requestBody: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Api.MapAI.ResolveParams' - required: true - parameters: - - name: id - in: path + $ref: '#/components/schemas/PlaceOS.Api.MicrosoftNotification' required: true - schema: - type: string - format: uuid + parameters: [] responses: "200": description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/PlaceOS.Api.MapAI.JobResponse' "409": description: Conflict content: @@ -22059,42 +23991,60 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/maps/ai/usage: - summary: AI editing of a level's SVG map, with the signage AI provider rows. - description: 'AI editing of a level''s SVG map, with the signage AI provider rows. - - - The editor posts the instruction and the context it built from the map (the - - element index, the shortlisted subtrees, the annotations and a marked up - - snapshot by upload id). The answer is a list of operations the editor - - applies, reviews and saves itself; nothing here reads or writes the map. - - One job is one vendor call, long polled through `jobs/:id` as signage is.' + /api/engine/v2/oauth_apps: + summary: OAuth applications, the clients permitted to sign users in to PlaceOS get: - summary: map edit spend per provider and model + summary: lists the frontend applications enabled on the domains tags: - - MapAI - operationId: PlaceOS::Api::MapAI_usage + - OAuthApplications + operationId: PlaceOS::Api::OAuthApplications_index parameters: - - name: from + - name: authority_id in: query - description: unix seconds, defaults to 30 days ago + description: the ID of the domain to be listed; ignored for non-admin/support + callers (forced to their own authority) + example: auth-12345 schema: anyOf: - - type: integer - format: Int64 - type: "null" - - name: to + - type: string + - name: q in: query - description: unix seconds, defaults to now + description: 'filters results by the given text: a record matches when any + word matches its searchable fields as a whole word, and the last word also + matches as a prefix (search as you type)' + schema: + type: string + - name: limit + in: query + description: the maximum number of results to return + example: 10000 + schema: + type: integer + format: UInt32 + - name: offset + in: query + description: the starting offset of the result set, used to implement pagination + schema: + type: integer + format: UInt32 + - name: ref + in: query + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: anyOf: - - type: integer - format: Int64 - type: "null" + - type: string + - name: fields + in: query + description: deprecated, ignored — search covers the resource's indexed fields + style: form + explode: false + schema: + type: array + items: + type: string responses: "200": description: OK @@ -22103,7 +24053,7 @@ paths: schema: type: array items: - $ref: '#/components/schemas/PlaceOS.Model.SignageAIJob.UsageRow' + $ref: '#/components/schemas/PlaceOS.Model.DoorkeeperApplication' "409": description: Conflict content: @@ -22182,47 +24132,25 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/metadata/{name}/bulk: - summary: Metadata, named JSON documents attached to zones, systems and users (e.g. - floor maps, booking rules) - get: - summary: Fetch metadata with a specific name for multiple resources - description: 'Fetch metadata with a specific name for multiple resources - - - Allows bulk retrieval of metadata across multiple parent entities' + post: + summary: add a new user interface application tags: - - Metadata - operationId: PlaceOS::Api::Metadata_bulk_fetch - parameters: - - name: name - in: path - description: the name of the metadata key to fetch - example: settings - required: true - schema: - type: string - - name: parent_ids - in: query - description: comma-separated list of parent IDs (zones, systems, users, etc.) - example: - - zone-1 - - zone-2 - - zone-3 + - OAuthApplications + operationId: PlaceOS::Api::OAuthApplications_create + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Model.DoorkeeperApplication' required: true - style: form - explode: false - schema: - type: array - items: - type: string + parameters: [] responses: - "200": - description: OK + "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/Hash-oString-nPlaceOS.Model.Metadata.Interface-pNil-c' + $ref: '#/components/schemas/PlaceOS.Model.DoorkeeperApplication' "409": description: Conflict content: @@ -22301,40 +24229,26 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/metadata/{id}: - summary: Metadata, named JSON documents attached to zones, systems and users (e.g. - floor maps, booking rules) + /api/engine/v2/oauth_apps/{id}: + summary: OAuth applications, the clients permitted to sign users in to PlaceOS get: - summary: Fetch metadata for a model - description: 'Fetch metadata for a model - - - Filter for a specific metadata by name via `name` param' + summary: show the details of the applications tags: - - Metadata - operationId: PlaceOS::Api::Metadata_show + - OAuthApplications + operationId: PlaceOS::Api::OAuthApplications_show parameters: - name: id in: path - description: the parent id of the metadata to be returned required: true schema: type: string - - name: name - in: query - description: the name of the metadata key - example: config - schema: - anyOf: - - type: "null" - - type: string responses: "200": description: OK content: application/json: schema: - $ref: '#/components/schemas/Hash-oString-nPlaceOS.Model.Metadata.Interface-c' + $ref: '#/components/schemas/PlaceOS.Model.DoorkeeperApplication' "409": description: Conflict content: @@ -22414,23 +24328,19 @@ paths: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' put: - summary: replace the metadata with this new metadata - description: 'replace the metadata with this new metadata - - udpates are signalled on the `placeos/metadata/changed` channel' + summary: udpate an application tags: - - Metadata - operationId: PlaceOS::Api::Metadata_update + - OAuthApplications + operationId: PlaceOS::Api::OAuthApplications_update requestBody: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.Metadata.Interface' + $ref: '#/components/schemas/PlaceOS.Model.DoorkeeperApplication' required: true parameters: - name: id in: path - description: the parent id of the metadata to be replaced required: true schema: type: string @@ -22440,7 +24350,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.Metadata.Interface' + $ref: '#/components/schemas/PlaceOS.Model.DoorkeeperApplication' "409": description: Conflict content: @@ -22520,21 +24430,13 @@ paths: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' delete: - summary: remove a metadata entry from the database + summary: remove an application tags: - - Metadata - operationId: PlaceOS::Api::Metadata_destroy + - OAuthApplications + operationId: PlaceOS::Api::OAuthApplications_destroy parameters: - name: id in: path - description: the parent id of the metadata to be returned - required: true - schema: - type: string - - name: name - in: query - description: the name of the metadata key - example: config required: true schema: type: string @@ -22620,23 +24522,19 @@ paths: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' patch: - summary: update only the keys provided on the selected metadata - description: 'update only the keys provided on the selected metadata - - udpates are signalled on the `placeos/metadata/changed` channel' + summary: udpate an application tags: - - Metadata - operationId: PlaceOS::Api::Metadata_merge + - OAuthApplications + operationId: PlaceOS::Api::OAuthApplications_update_2 requestBody: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.Metadata.Interface' + $ref: '#/components/schemas/PlaceOS.Model.DoorkeeperApplication' required: true parameters: - name: id in: path - description: the parent id of the metadata to be updated required: true schema: type: string @@ -22646,7 +24544,99 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.Metadata.Interface' + $ref: '#/components/schemas/PlaceOS.Model.DoorkeeperApplication' + "409": + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "429": + description: Too Many Requests + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ImageGenError' + "422": + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "502": + description: Bad Gateway + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "504": + description: Gateway Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "406": + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + "408": + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.CommonError' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ParameterError' + "415": + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' + /api/engine/v2/organisations/current: + summary: 'Organisations: the customers that own domains and estate (PPT-526)' + get: + tags: + - Organisations + operationId: PlaceOS::Api::Organisations_current + parameters: [] + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Organisations.CurrentReach' "409": description: Conflict content: @@ -22725,41 +24715,60 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/metadata/{id}/children: - summary: Metadata, named JSON documents attached to zones, systems and users (e.g. - floor maps, booking rules) + /api/engine/v2/organisations: + summary: 'Organisations: the customers that own domains and estate (PPT-526)' get: - summary: Fetch metadata for Zone children - description: 'Fetch metadata for Zone children - - - Filter for a specific metadata by name via `name` param. - - Includes the parent metadata by default via `include_parent` param.' + summary: list the organisations in reach tags: - - Metadata - operationId: PlaceOS::Api::Metadata_children_metadata + - Organisations + operationId: PlaceOS::Api::Organisations_index parameters: - - name: id - in: path - description: the parent id of the metadata to be returned - required: true + - name: partner_id + in: query + description: only organisations under this partner + example: 0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f + schema: + anyOf: + - type: string + format: uuid + - type: "null" + - name: q + in: query + description: 'filters results by the given text: a record matches when any + word matches its searchable fields as a whole word, and the last word also + matches as a prefix (search as you type)' schema: type: string - - name: include_parent + - name: limit in: query - description: the parent metadata is included in the results by default - example: false + description: the maximum number of results to return + example: 10000 schema: - type: boolean - - name: name + type: integer + format: UInt32 + - name: offset in: query - description: filter for a particular metadata key - example: config + description: the starting offset of the result set, used to implement pagination + schema: + type: integer + format: UInt32 + - name: ref + in: query + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: anyOf: - type: "null" - type: string + - name: fields + in: query + description: deprecated, ignored — search covers the resource's indexed fields + style: form + explode: false + schema: + type: array + items: + type: string responses: "200": description: OK @@ -22768,7 +24777,7 @@ paths: schema: type: array items: - $ref: '#/components/schemas/PlaceOS.Api.Metadata.Children' + $ref: '#/components/schemas/PlaceOS.Model.Organisation' "409": description: Conflict content: @@ -22847,37 +24856,46 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/metadata/{id}/name: - summary: Metadata, named JSON documents attached to zones, systems and users (e.g. - floor maps, booking rules) - patch: - summary: rename a metadata field - description: 'rename a metadata field + post: + summary: '`payer` and `partner_staff` are not mass-assignable, so they travel + as' + description: '`payer` and `partner_staff` are not mass-assignable, so they travel + as - udpates are signalled on the `placeos/metadata/changed` channel' + parameters' tags: - - Metadata - operationId: PlaceOS::Api::Metadata_rename + - Organisations + operationId: PlaceOS::Api::Organisations_create requestBody: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Api.Metadata.Rename' + $ref: '#/components/schemas/PlaceOS.Model.Organisation' required: true parameters: - - name: id - in: path - description: the parent id of the metadata to be renamed - required: true + - name: payer + in: query + description: 'who is invoiced: partner or organisation' + example: partner schema: - type: string + anyOf: + - type: "null" + - type: string + - name: partner_staff + in: query + description: true for the partner's own staff organisation + example: false + schema: + anyOf: + - type: boolean + - type: "null" responses: - "200": - description: OK + "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.Metadata.Interface' + $ref: '#/components/schemas/PlaceOS.Model.Organisation' "409": description: Conflict content: @@ -22956,49 +24974,26 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/metadata/{id}/history: - summary: Metadata, named JSON documents attached to zones, systems and users (e.g. - floor maps, booking rules) + /api/engine/v2/organisations/{id}: + summary: 'Organisations: the customers that own domains and estate (PPT-526)' get: - summary: Returns the version history for a Settings model tags: - - Metadata - operationId: PlaceOS::Api::Metadata_history + - Organisations + operationId: PlaceOS::Api::Organisations_show parameters: - name: id in: path - description: the parent id of the metadata to be returned required: true schema: type: string - - name: name - in: query - description: the name of the metadata key - example: config - schema: - anyOf: - - type: "null" - - type: string - - name: limit - in: query - description: the maximum number of results to return - example: 10000 - schema: - type: integer - format: Int32 - - name: offset - in: query - description: the starting offset of the result set. Used to implement pagination - schema: - type: integer - format: Int32 + format: uuid responses: "200": description: OK content: application/json: schema: - $ref: '#/components/schemas/Hash-oString-nArray-oPlaceOS.Model.Metadata.Interface-c-c' + $ref: '#/components/schemas/PlaceOS.Model.Organisation' "409": description: Conflict content: @@ -23077,19 +25072,46 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/mqtt/user: - post: - summary: 'For MQTT JWT access: https://github.com/iegomez/mosquitto-go-auth#remote-mode' - description: 'For MQTT JWT access: https://github.com/iegomez/mosquitto-go-auth#remote-mode - - jwt_response_mode: status, jwt_params_mode: form' + put: tags: - - MQTT - operationId: PlaceOS::Api::MQTT_mqtt_user - parameters: [] + - Organisations + operationId: PlaceOS::Api::Organisations_update + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Model.Organisation' + required: true + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: payer + in: query + description: 'who is invoiced: partner or organisation' + example: partner + schema: + anyOf: + - type: "null" + - type: string + - name: partner_staff + in: query + description: true for the partner's own staff organisation + example: false + schema: + anyOf: + - type: boolean + - type: "null" responses: "200": description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Model.Organisation' "409": description: Conflict content: @@ -23168,38 +25190,21 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/mqtt/access: - post: - summary: 'Sends a form with the following params: topic, clientid, acc (1: read, - 2: write, 3: readwrite, 4: subscribe)' - description: 'Sends a form with the following params: topic, clientid, acc (1: - read, 2: write, 3: readwrite, 4: subscribe) - - example payload: acc=4&clientid=clientId-NwsUNfV30&topic=%2A' + delete: + summary: Deletes are RESTRICTed by the database while anything is owned tags: - - MQTT - operationId: PlaceOS::Api::MQTT_mqtt_access + - Organisations + operationId: PlaceOS::Api::Organisations_destroy parameters: - - name: topic - in: query + - name: id + in: path required: true schema: type: string - - name: acc - in: query - description: access level required - required: true - schema: - $ref: '#/components/schemas/PlaceOS.Api.MQTT.MqttAcl' - - name: clientid - in: query - schema: - anyOf: - - type: "null" - - type: string + format: uuid responses: - "200": - description: OK + "202": + description: Accepted "409": description: Conflict content: @@ -23278,15 +25283,46 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/notifications/google: - post: + patch: tags: - - PushNotifications - operationId: PlaceOS::Api::PushNotifications_google - parameters: [] + - Organisations + operationId: PlaceOS::Api::Organisations_update_2 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Model.Organisation' + required: true + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: payer + in: query + description: 'who is invoiced: partner or organisation' + example: partner + schema: + anyOf: + - type: "null" + - type: string + - name: partner_staff + in: query + description: true for the partner's own staff organisation + example: false + schema: + anyOf: + - type: boolean + - type: "null" responses: "200": description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Model.Organisation' "409": description: Conflict content: @@ -23365,21 +25401,39 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/notifications/office365: + /api/engine/v2/organisations/{id}/claim: + summary: 'Organisations: the customers that own domains and estate (PPT-526)' post: + summary: 'Assigns unowned zone trees to the organisation: the zones named, their' + description: 'Assigns unowned zone trees to the organisation: the zones named, + their + + descendants, the systems in those zones and the modules of those + + systems. Rows already owned by another organisation are left alone.' tags: - - PushNotifications - operationId: PlaceOS::Api::PushNotifications_microsoft + - Organisations + operationId: PlaceOS::Api::Organisations_claim requestBody: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Api.MicrosoftNotification' + $ref: '#/components/schemas/PlaceOS.Api.Organisations.ClaimBody' required: true - parameters: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid responses: "200": description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS.Api.Organisations.ClaimResult' "409": description: Conflict content: @@ -23458,23 +25512,18 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/oauth_apps: - summary: OAuth applications, the clients permitted to sign users in to PlaceOS + /api/engine/v2/partners: + summary: 'Partners: integrators that bring in and manage organisations, and the' + description: 'Partners: integrators that bring in and manage organisations, and + the + + management partner that is PlaceOS itself (PPT-526)' get: - summary: lists the frontend applications enabled on the domains + summary: cluster admins list every partner; everyone else sees their own tags: - - OAuthApplications - operationId: PlaceOS::Api::OAuthApplications_index + - Partners + operationId: PlaceOS::Api::Partners_index parameters: - - name: authority_id - in: query - description: the ID of the domain to be listed; ignored for non-admin/support - callers (forced to their own authority) - example: auth-12345 - schema: - anyOf: - - type: "null" - - type: string - name: q in: query description: 'filters results by the given text: a record matches when any @@ -23520,7 +25569,7 @@ paths: schema: type: array items: - $ref: '#/components/schemas/PlaceOS.Model.DoorkeeperApplication' + $ref: '#/components/schemas/PlaceOS.Model.Partner' "409": description: Conflict content: @@ -23600,15 +25649,14 @@ paths: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' post: - summary: add a new user interface application tags: - - OAuthApplications - operationId: PlaceOS::Api::OAuthApplications_create + - Partners + operationId: PlaceOS::Api::Partners_create requestBody: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.DoorkeeperApplication' + $ref: '#/components/schemas/PlaceOS.Model.Partner' required: true parameters: [] responses: @@ -23617,7 +25665,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.DoorkeeperApplication' + $ref: '#/components/schemas/PlaceOS.Model.Partner' "409": description: Conflict content: @@ -23696,26 +25744,30 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' - /api/engine/v2/oauth_apps/{id}: - summary: OAuth applications, the clients permitted to sign users in to PlaceOS + /api/engine/v2/partners/{id}: + summary: 'Partners: integrators that bring in and manage organisations, and the' + description: 'Partners: integrators that bring in and manage organisations, and + the + + management partner that is PlaceOS itself (PPT-526)' get: - summary: show the details of the applications tags: - - OAuthApplications - operationId: PlaceOS::Api::OAuthApplications_show + - Partners + operationId: PlaceOS::Api::Partners_show parameters: - name: id in: path required: true schema: type: string + format: uuid responses: "200": description: OK content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.DoorkeeperApplication' + $ref: '#/components/schemas/PlaceOS.Model.Partner' "409": description: Conflict content: @@ -23795,15 +25847,14 @@ paths: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' put: - summary: udpate an application tags: - - OAuthApplications - operationId: PlaceOS::Api::OAuthApplications_update + - Partners + operationId: PlaceOS::Api::Partners_update requestBody: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.DoorkeeperApplication' + $ref: '#/components/schemas/PlaceOS.Model.Partner' required: true parameters: - name: id @@ -23811,13 +25862,14 @@ paths: required: true schema: type: string + format: uuid responses: "200": description: OK content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.DoorkeeperApplication' + $ref: '#/components/schemas/PlaceOS.Model.Partner' "409": description: Conflict content: @@ -23897,16 +25949,17 @@ paths: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' delete: - summary: remove an application + summary: Deletes are RESTRICTed by the database while organisations remain tags: - - OAuthApplications - operationId: PlaceOS::Api::OAuthApplications_destroy + - Partners + operationId: PlaceOS::Api::Partners_destroy parameters: - name: id in: path required: true schema: type: string + format: uuid responses: "202": description: Accepted @@ -23989,15 +26042,14 @@ paths: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' patch: - summary: udpate an application tags: - - OAuthApplications - operationId: PlaceOS::Api::OAuthApplications_update_2 + - Partners + operationId: PlaceOS::Api::Partners_update_2 requestBody: content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.DoorkeeperApplication' + $ref: '#/components/schemas/PlaceOS.Model.Partner' required: true parameters: - name: id @@ -24005,13 +26057,14 @@ paths: required: true schema: type: string + format: uuid responses: "200": description: OK content: application/json: schema: - $ref: '#/components/schemas/PlaceOS.Model.DoorkeeperApplication' + $ref: '#/components/schemas/PlaceOS.Model.Partner' "409": description: Conflict content: @@ -39538,6 +41591,16 @@ paths: - Triggers operationId: PlaceOS::Api::Triggers_index parameters: + - name: organisation_id + in: query + description: only triggers bound to systems owned by this organisation; templates + are always included + example: 0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f + schema: + anyOf: + - type: string + format: uuid + - type: "null" - name: q in: query description: 'filters results by the given text: a record matches when any @@ -42031,6 +44094,16 @@ paths: anyOf: - type: "null" - type: string + - name: organisation_id + in: query + description: admin users can list the users of every domain owned by this + organisation (must be within reach) + example: 0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f + schema: + anyOf: + - type: string + format: uuid + - type: "null" - name: q in: query description: 'filters results by the given text: a record matches when any @@ -44109,6 +46182,15 @@ paths: example: true schema: type: boolean + - name: organisation_id + in: query + description: only rows owned by this organisation (must be within reach) + example: 0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f + schema: + anyOf: + - type: string + format: uuid + - type: "null" - name: q in: query description: 'filters results by the given text: a record matches when any @@ -44234,7 +46316,14 @@ paths: schema: $ref: '#/components/schemas/PlaceOS.Api.Application.ContentError' post: - summary: add a new zone + summary: add a new zone. A child zone belongs to its parent's organisation; + a + description: 'add a new zone. A child zone belongs to its parent''s organisation; + a + + root zone belongs to the caller''s, or to the organisation a cluster + + admin names.' tags: - Zones operationId: PlaceOS::Api::Zones_create @@ -44244,7 +46333,16 @@ paths: schema: $ref: '#/components/schemas/PlaceOS.Model.Zone' required: true - parameters: [] + parameters: + - name: organisation_id + in: query + description: the organisation that owns a new root zone (cluster admins only) + example: 0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f + schema: + anyOf: + - type: string + format: uuid + - type: "null" responses: "201": description: Created @@ -45929,6 +48027,11 @@ components: - type: array items: type: string + organisation_id: + anyOf: + - type: string + format: uuid + - type: "null" id: anyOf: - type: "null" @@ -46124,6 +48227,11 @@ components: - type: array items: type: string + organisation_id: + anyOf: + - type: string + format: uuid + - type: "null" id: anyOf: - type: "null" @@ -46335,6 +48443,11 @@ components: anyOf: - type: boolean - type: "null" + organisation_id: + anyOf: + - type: string + format: uuid + - type: "null" has_runtime_error: anyOf: - type: boolean @@ -46472,6 +48585,11 @@ components: - type: array items: type: string + organisation_id: + anyOf: + - type: string + format: uuid + - type: "null" timezone: anyOf: - type: object @@ -46710,6 +48828,11 @@ components: - type: array items: type: string + organisation_id: + anyOf: + - type: string + format: uuid + - type: "null" place_id: anyOf: - type: "null" @@ -46843,6 +48966,11 @@ components: anyOf: - type: boolean - type: "null" + organisation_id: + anyOf: + - type: string + format: uuid + - type: "null" id: anyOf: - type: "null" @@ -46883,6 +49011,11 @@ components: - type: "null" online: type: boolean + organisation_id: + anyOf: + - type: string + format: uuid + - type: "null" x_api_key: type: string required: @@ -47034,6 +49167,72 @@ components: - total_modules - failed_modules - timestamp + PlaceOS.Model.Grant: + type: object + properties: + created_at: + anyOf: + - type: string + format: date-time + - type: "null" + updated_at: + anyOf: + - type: string + format: date-time + - type: "null" + id: + anyOf: + - type: string + format: uuid + - type: "null" + user_id: + anyOf: + - type: "null" + - type: string + scope_type: + anyOf: + - type: "null" + - type: string + scope_id: + anyOf: + - type: "null" + - type: string + permissions: + anyOf: + - type: integer + format: Int32 + - type: "null" + expires_at: + anyOf: + - type: string + format: date-time + - type: "null" + granted_by: + anyOf: + - type: "null" + - type: string + PlaceOS.Api.Grants.GrantBody: + type: object + properties: + user_id: + type: string + scope_type: + type: string + scope_id: + type: string + permissions: + type: integer + format: Int32 + expires_at: + anyOf: + - type: string + format: date-time + - type: "null" + required: + - user_id + - scope_type + - scope_id + - permissions PlaceOS.Model.Group: type: object properties: @@ -47689,6 +49888,149 @@ components: - type: string format: date-time - type: "null" + PlaceOS.Api.Organisations.CurrentReach: + type: object + properties: + reach: + type: string + enforcing: + type: boolean + organisation: + anyOf: + - type: "null" + - $ref: '#/components/schemas/PlaceOS.Model.Organisation' + partner: + anyOf: + - type: "null" + - $ref: '#/components/schemas/PlaceOS.Model.Partner' + organisations: + anyOf: + - type: "null" + - type: array + items: + $ref: '#/components/schemas/PlaceOS.Model.Organisation' + required: + - reach + - enforcing + description: 'What the caller can reach: the level, their own organisation and + partner, + + and the organisations in reach. Backoffice reads this once at login.' + PlaceOS.Model.Organisation: + type: object + properties: + created_at: + anyOf: + - type: string + format: date-time + - type: "null" + updated_at: + anyOf: + - type: string + format: date-time + - type: "null" + id: + anyOf: + - type: string + format: uuid + - type: "null" + name: + anyOf: + - type: "null" + - type: string + description: + anyOf: + - type: "null" + - type: string + payer: + anyOf: + - type: "null" + - type: string + config: + anyOf: + - type: "null" + - type: object + additionalProperties: + type: object + partner_staff: + anyOf: + - type: boolean + - type: "null" + partner_id: + anyOf: + - type: string + format: uuid + - type: "null" + PlaceOS.Api.Organisations.ClaimBody: + type: object + properties: + zone_ids: + type: array + items: + type: string + required: + - zone_ids + PlaceOS.Api.Organisations.ClaimResult: + type: object + properties: + zones: + type: integer + format: Int64 + systems: + type: integer + format: Int64 + modules: + type: integer + format: Int64 + triggers: + type: integer + format: Int64 + required: + - zones + - systems + - modules + - triggers + PlaceOS.Model.Partner: + type: object + properties: + created_at: + anyOf: + - type: string + format: date-time + - type: "null" + updated_at: + anyOf: + - type: string + format: date-time + - type: "null" + id: + anyOf: + - type: string + format: uuid + - type: "null" + name: + anyOf: + - type: "null" + - type: string + description: + anyOf: + - type: "null" + - type: string + management: + anyOf: + - type: boolean + - type: "null" + config: + anyOf: + - type: "null" + - type: object + additionalProperties: + type: object + parent_id: + anyOf: + - type: string + format: uuid + - type: "null" PlaceOS.Model.PendingMail: type: object properties: @@ -49209,6 +51551,11 @@ components: - type: array items: type: string + organisation_id: + anyOf: + - type: string + format: uuid + - type: "null" control_system_id: anyOf: - type: "null" diff --git a/shard.lock b/shard.lock index a161e399..31496e8a 100644 --- a/shard.lock +++ b/shard.lock @@ -187,7 +187,7 @@ shards: pg-orm: git: https://github.com/spider-gazelle/pg-orm.git - version: 2.5.2 + version: 2.5.3 pinger: git: https://github.com/spider-gazelle/pinger.git @@ -221,9 +221,9 @@ shards: git: https://github.com/place-labs/log-backend.git version: 0.13.0 - placeos-models: + placeos-models: # Overridden git: https://github.com/placeos/models.git - version: 9.123.4 + version: 9.123.4+git.commit.b50200942c7e6bf30d999c1f500ce1375408edc0 placeos-resource: git: https://github.com/place-labs/resource.git diff --git a/shard.override.yml b/shard.override.yml index 4667f5a5..2a60a444 100644 --- a/shard.override.yml +++ b/shard.override.yml @@ -18,7 +18,7 @@ dependencies: github: place-labs/ulid branch: master - # uncomment to test different model branches - # placeos-models: - # github: placeos/models - # branch: add-signage-groups + # PPT-526: build against the multi-tenancy data layer until models #332 merges + placeos-models: + github: placeos/models + branch: PPT-526-partner-client diff --git a/spec/controllers/tenancy_spec.cr b/spec/controllers/tenancy_spec.cr new file mode 100644 index 00000000..b437307f --- /dev/null +++ b/spec/controllers/tenancy_spec.cr @@ -0,0 +1,313 @@ +require "../helper" + +private def tenancy_get(path, user) + client.get(path, headers: PlaceOS::Api::Spec::Tenancy.headers(user)) +end + +private def tenancy_post(path, user, body) + client.post(path, headers: PlaceOS::Api::Spec::Tenancy.headers(user), body: body.to_json) +end + +module PlaceOS::Api + # PPT-526: who can see and touch what across organisations on a shared cluster + describe "tenancy" do + fixture = nil.as(Spec::Tenancy::Fixture?) + build = -> { fixture = Spec::Tenancy.build } + + before_each { Utils::Tenancy.enforce = true } + after_each do + Utils::Tenancy.enforce = false + if f = fixture + Spec::Tenancy.teardown(f) + fixture = nil + end + end + + describe "reach" do + it "resolves cluster, partner and organisation reach" do + f = build.call.not_nil! + + cluster = JSON.parse(tenancy_get("#{Organisations.base_route}current", f.placeos.admin).body) + cluster["reach"].should eq "cluster" + cluster["enforcing"].as_bool.should be_true + cluster["organisations"]?.should be_nil + + partner = JSON.parse(tenancy_get("#{Organisations.base_route}current", f.ntt_staff.admin).body) + partner["reach"].should eq "partner" + partner["organisations"].as_a.map(&.["id"].to_s).sort!.should eq [f.ntt_staff.organisation.id.to_s, f.acadian.organisation.id.to_s].sort + + org = JSON.parse(tenancy_get("#{Organisations.base_route}current", f.acadian.admin).body) + org["reach"].should eq "organisation" + org["organisations"].as_a.map(&.["id"].to_s).should eq [f.acadian.organisation.id.to_s] + + plain = JSON.parse(tenancy_get("#{Organisations.base_route}current", f.placeos.user).body) + plain["reach"].should eq "organisation" + end + end + + describe "estate lists" do + it "scopes zones, systems and modules to the caller's organisations" do + f = build.call.not_nil! + + zones = Spec::Tenancy.ids(tenancy_get("#{Zones.base_route}?limit=500", f.acadian.admin).body) + zones.should contain(f.acadian.org_zone.id) + zones.should_not contain(f.ucla.org_zone.id) + zones.should_not contain(f.unowned_zone.id) + + partner_zones = Spec::Tenancy.ids(tenancy_get("#{Zones.base_route}?limit=500", f.ntt_staff.support).body) + partner_zones.should contain(f.acadian.org_zone.id) + partner_zones.should contain(f.ntt_staff.org_zone.id) + partner_zones.should_not contain(f.ucla.org_zone.id) + + cluster_zones = Spec::Tenancy.ids(tenancy_get("#{Zones.base_route}?limit=500", f.placeos.admin).body) + cluster_zones.should contain(f.ucla.org_zone.id) + cluster_zones.should contain(f.unowned_zone.id) + + systems = Spec::Tenancy.ids(tenancy_get("#{Systems.base_route}?limit=500", f.ucla.admin).body) + systems.should eq [f.ucla.system.id] + + modules = Spec::Tenancy.ids(tenancy_get("#{Modules.base_route}?limit=500", f.ucla.admin).body) + modules.should eq [f.ucla.mod.id] + + tenancy_get("#{Zones.base_route}tags", f.acadian.admin).status_code.should eq 200 + end + + it "answers 404 for another organisation's rows and 200 for its own" do + f = build.call.not_nil! + + tenancy_get("#{Zones.base_route}#{f.ucla.org_zone.id}", f.acadian.admin).status_code.should eq 404 + tenancy_get("#{Zones.base_route}#{f.acadian.org_zone.id}", f.acadian.admin).status_code.should eq 200 + tenancy_get("#{Systems.base_route}#{f.ucla.system.id}", f.acadian.admin).status_code.should eq 404 + tenancy_get("#{Modules.base_route}#{f.ucla.mod.id}", f.acadian.admin).status_code.should eq 404 + tenancy_get("#{Zones.base_route}#{f.unowned_zone.id}", f.acadian.admin).status_code.should eq 404 + + # partner staff reach their client, not a stranger + tenancy_get("#{Zones.base_route}#{f.acadian.org_zone.id}", f.ntt_staff.admin).status_code.should eq 200 + tenancy_get("#{Zones.base_route}#{f.ucla.org_zone.id}", f.ntt_staff.admin).status_code.should eq 404 + + # cluster admins reach everything, unowned rows included + tenancy_get("#{Zones.base_route}#{f.ucla.org_zone.id}", f.placeos.admin).status_code.should eq 200 + tenancy_get("#{Zones.base_route}#{f.unowned_zone.id}", f.placeos.admin).status_code.should eq 200 + end + + it "narrows a list to one organisation on request" do + f = build.call.not_nil! + + narrowed = Spec::Tenancy.ids(tenancy_get("#{Zones.base_route}?organisation_id=#{f.ucla.organisation.id}&limit=500", f.placeos.admin).body) + narrowed.should contain(f.ucla.org_zone.id) + narrowed.should_not contain(f.acadian.org_zone.id) + narrowed.should_not contain(f.unowned_zone.id) + + partner_narrowed = Spec::Tenancy.ids(tenancy_get("#{Systems.base_route}?organisation_id=#{f.acadian.organisation.id}", f.ntt_staff.admin).body) + partner_narrowed.should eq [f.acadian.system.id] + + tenancy_get("#{Zones.base_route}?organisation_id=#{f.ucla.organisation.id}", f.acadian.admin).status_code.should eq 404 + + users = Spec::Tenancy.ids(tenancy_get("#{Users.base_route}?organisation_id=#{f.acadian.organisation.id}", f.placeos.admin).body) + users.should contain(f.acadian.user.id) + users.should_not contain(f.ucla.user.id) + end + + it "only logs when enforcement is off" do + f = build.call.not_nil! + Utils::Tenancy.enforce = false + tenancy_get("#{Zones.base_route}#{f.ucla.org_zone.id}", f.acadian.admin).status_code.should eq 200 + end + end + + describe "estate writes" do + it "owns new zones through the caller or the parent" do + f = build.call.not_nil! + + root = tenancy_post(Zones.base_route, f.acadian.admin, {name: "Acadian root #{random_name}", tags: ["building"]}) + root.status_code.should eq 201 + JSON.parse(root.body)["organisation_id"].to_s.should eq f.acadian.organisation.id.to_s + + child = tenancy_post(Zones.base_route, f.acadian.admin, {name: "Level #{random_name}", parent_id: f.acadian.building.id}) + child.status_code.should eq 201 + JSON.parse(child.body)["organisation_id"].to_s.should eq f.acadian.organisation.id.to_s + + foreign_parent = tenancy_post(Zones.base_route, f.acadian.admin, {name: "Sneaky #{random_name}", parent_id: f.ucla.building.id}) + foreign_parent.status_code.should eq 404 + + named = client.post("#{Zones.base_route}?organisation_id=#{f.ucla.organisation.id}", headers: Spec::Tenancy.headers(f.placeos.admin), body: {name: "UCLA root #{random_name}", tags: ["building"]}.to_json) + named.status_code.should eq 201 + JSON.parse(named.body)["organisation_id"].to_s.should eq f.ucla.organisation.id.to_s + + client.post("#{Zones.base_route}?organisation_id=#{f.ucla.organisation.id}", headers: Spec::Tenancy.headers(f.acadian.admin), body: {name: "Not mine #{random_name}"}.to_json).status_code.should eq 403 + end + + it "owns new systems through their zones and refuses mixed zones" do + f = build.call.not_nil! + + own = tenancy_post(Systems.base_route, f.acadian.admin, {name: "Room #{random_name}", zones: [f.acadian.building.id]}) + own.status_code.should eq 201 + JSON.parse(own.body)["organisation_id"].to_s.should eq f.acadian.organisation.id.to_s + + tenancy_post(Systems.base_route, f.acadian.admin, {name: "Room #{random_name}", zones: [f.ucla.building.id]}).status_code.should eq 404 + + mixed = tenancy_post(Systems.base_route, f.placeos.admin, {name: "Room #{random_name}", zones: [f.acadian.building.id, f.ucla.building.id]}) + mixed.status_code.should eq 422 + end + + it "keeps modules inside the system's organisation" do + f = build.call.not_nil! + + attach = client.put("#{Systems.base_route}#{f.acadian.system.id}/module/#{f.ucla.mod.id}", headers: Spec::Tenancy.headers(f.acadian.admin)) + attach.status_code.should eq 404 + + attach = client.put("#{Systems.base_route}#{f.ucla.system.id}/module/#{f.ucla.mod.id}", headers: Spec::Tenancy.headers(f.ucla.admin)) + attach.status_code.should eq 200 + end + end + + describe "domains and users" do + it "lists and creates domains within reach" do + f = build.call.not_nil! + + domains = Spec::Tenancy.ids(tenancy_get("#{Domains.base_route}?limit=500", f.acadian.admin).body) + domains.should eq [f.acadian.authority.id] + tenancy_get("#{Domains.base_route}#{f.ucla.authority.id}", f.acadian.admin).status_code.should eq 404 + + created = tenancy_post(Domains.base_route, f.acadian.admin, {name: "Acadian two", domain: "two.acadian.test"}) + created.status_code.should eq 201 + JSON.parse(created.body)["organisation_id"].to_s.should eq f.acadian.organisation.id.to_s + + staff = client.post("#{Domains.base_route}?organisation_id=#{f.ucla.organisation.id}", headers: Spec::Tenancy.headers(f.placeos.admin), body: {name: "UCLA two", domain: "two.ucla.test"}.to_json) + staff.status_code.should eq 201 + JSON.parse(staff.body)["organisation_id"].to_s.should eq f.ucla.organisation.id.to_s + + moved = client.patch("#{Domains.base_route}#{f.acadian.authority.id}?organisation_id=#{f.ucla.organisation.id}", headers: Spec::Tenancy.headers(f.acadian.admin), body: {name: "renamed"}.to_json) + moved.status_code.should eq 403 + end + + it "lists users within reach only" do + f = build.call.not_nil! + + users = Spec::Tenancy.ids(tenancy_get("#{Users.base_route}?limit=500", f.acadian.admin).body) + users.should contain(f.acadian.user.id) + users.should_not contain(f.ucla.user.id) + + tenancy_get("#{Users.base_route}?authority_id=#{f.ucla.authority.id}", f.acadian.admin).status_code.should eq 404 + tenancy_get("#{Users.base_route}#{f.ucla.user.id}", f.acadian.admin).status_code.should eq 404 + + partner_users = Spec::Tenancy.ids(tenancy_get("#{Users.base_route}?limit=500", f.ntt_staff.admin).body) + partner_users.should contain(f.acadian.user.id) + partner_users.should_not contain(f.ucla.user.id) + end + end + + describe "last organisation admin" do + it "refuses an organisation admin removing their last admin, allows cluster staff" do + f = build.call.not_nil! + + client.delete("#{Users.base_route}#{f.acadian.admin.id}", headers: Spec::Tenancy.headers(f.acadian.admin)).status_code.should eq 403 + + second = Model::Generator.user(f.acadian.authority, admin: true).save! + client.delete("#{Users.base_route}#{second.id}", headers: Spec::Tenancy.headers(f.acadian.admin)).status_code.should eq 202 + + client.delete("#{Users.base_route}#{f.acadian.admin.id}", headers: Spec::Tenancy.headers(f.placeos.admin)).status_code.should eq 202 + end + end + + describe "cluster-only resources" do + it "hides brokers and trigger templates' writes from organisation admins" do + f = build.call.not_nil! + + tenancy_get(Brokers.base_route, f.acadian.admin).status_code.should eq 403 + tenancy_get(Brokers.base_route, f.placeos.admin).status_code.should eq 200 + + template = tenancy_post(Triggers.base_route, f.acadian.admin, {name: "Template #{random_name}"}) + template.status_code.should eq 403 + template = tenancy_post(Triggers.base_route, f.placeos.admin, {name: "Template #{random_name}"}) + template.status_code.should eq 201 + + # templates are readable by every organisation + templates = Spec::Tenancy.ids(tenancy_get("#{Triggers.base_route}?limit=500", f.acadian.admin).body) + templates.should contain(JSON.parse(template.body)["id"].to_s) + + bound = tenancy_post(Triggers.base_route, f.acadian.admin, {name: "Bound #{random_name}", control_system_id: f.acadian.system.id}) + bound.status_code.should eq 201 + JSON.parse(bound.body)["organisation_id"].to_s.should eq f.acadian.organisation.id.to_s + Spec::Tenancy.ids(tenancy_get("#{Triggers.base_route}?limit=500", f.ucla.admin).body).should_not contain(JSON.parse(bound.body)["id"].to_s) + end + end + + describe "routes added since the reach enforcement" do + it "keeps change history and the source resync to cluster admins" do + f = build.call.not_nil! + + tenancy_get(History.base_route, f.acadian.admin).status_code.should eq 403 + tenancy_get(History.base_route, f.placeos.admin).status_code.should eq 200 + tenancy_post(File.join(History.base_route, "undelete?resource_type=zone&resource_id=#{f.ucla.org_zone.id}"), f.acadian.admin, nil).status_code.should eq 403 + + tenancy_post("/api/engine/v2/source/resync", f.acadian.admin, nil).status_code.should eq 403 + tenancy_get("/api/engine/v2/source/resync", f.acadian.admin).status_code.should eq 403 + end + + it "hides another organisation's zones from map edits, support users included" do + f = build.call.not_nil! + + result = tenancy_post(File.join(MapAI.base_route, "edit"), f.acadian.support, {zone_id: f.ucla.org_zone.id, instruction: "x", context: JSON.parse(File.read(File.join(__DIR__, "../fixtures/map_ai/context.json")))}) + result.status_code.should eq 404 + end + end + + describe "organisations, partners and grants" do + it "lets cluster admins manage the hierarchy and others read their own" do + f = build.call.not_nil! + + partner = tenancy_post(Partners.base_route, f.placeos.admin, {name: "Integrator #{random_name}"}) + partner.status_code.should eq 201 + partner_id = JSON.parse(partner.body)["id"].to_s + + organisation = client.post("#{Organisations.base_route}?partner_staff=true", headers: Spec::Tenancy.headers(f.placeos.admin), body: {name: "Integrator staff #{random_name}", partner_id: partner_id}.to_json) + organisation.status_code.should eq 201 + JSON.parse(organisation.body)["partner_staff"].as_bool.should be_true + + tenancy_post(Partners.base_route, f.acadian.admin, {name: "Nope #{random_name}"}).status_code.should eq 403 + + Spec::Tenancy.ids(tenancy_get("#{Organisations.base_route}?limit=500", f.acadian.admin).body).should eq [f.acadian.organisation.id.to_s] + Spec::Tenancy.ids(tenancy_get("#{Partners.base_route}?limit=500", f.acadian.admin).body).should eq [f.ntt.id.to_s] + tenancy_get("#{Partners.base_route}#{f.management.id}", f.acadian.admin).status_code.should eq 404 + + client.patch("#{Organisations.base_route}#{f.acadian.organisation.id}", headers: Spec::Tenancy.headers(f.acadian.admin), body: {name: "renamed"}.to_json).status_code.should eq 403 + end + + it "widens reach through live grants" do + f = build.call.not_nil! + + tenancy_get("#{Zones.base_route}#{f.acadian.org_zone.id}", f.ucla.admin).status_code.should eq 404 + + granted = tenancy_post(Grants.base_route, f.placeos.admin, {user_id: f.ucla.admin.id, scope_type: "organisation", scope_id: f.acadian.organisation.id.to_s, permissions: 1}) + granted.status_code.should eq 201 + tenancy_get("#{Zones.base_route}#{f.acadian.org_zone.id}", f.ucla.admin).status_code.should eq 200 + + client.delete("#{Grants.base_route}#{JSON.parse(granted.body)["id"]}", headers: Spec::Tenancy.headers(f.placeos.admin)).status_code.should eq 202 + tenancy_get("#{Zones.base_route}#{f.acadian.org_zone.id}", f.ucla.admin).status_code.should eq 404 + + expired = tenancy_post(Grants.base_route, f.placeos.admin, {user_id: f.ucla.admin.id, scope_type: "organisation", scope_id: f.acadian.organisation.id.to_s, permissions: 1, expires_at: 1.hour.ago}) + expired.status_code.should eq 201 + tenancy_get("#{Zones.base_route}#{f.acadian.org_zone.id}", f.ucla.admin).status_code.should eq 404 + + # an organisation admin may grant into their own organisation but not a stranger's + tenancy_post(Grants.base_route, f.acadian.admin, {user_id: f.ucla.support.id, scope_type: "organisation", scope_id: f.acadian.organisation.id.to_s}).status_code.should eq 201 + tenancy_post(Grants.base_route, f.acadian.admin, {user_id: f.acadian.admin.id, scope_type: "organisation", scope_id: f.ucla.organisation.id.to_s}).status_code.should eq 403 + end + + it "claims an unowned zone tree for an organisation" do + f = build.call.not_nil! + + claimed = tenancy_post("#{Organisations.base_route}#{f.ucla.organisation.id}/claim", f.placeos.admin, {zone_ids: [f.unowned_zone.id]}) + claimed.status_code.should eq 200 + result = JSON.parse(claimed.body) + result["zones"].should eq 1 + result["systems"].should eq 1 + + Model::Zone.find!(f.unowned_zone.id.as(String)).organisation_id.should eq f.ucla.organisation.id + Model::ControlSystem.find!(f.unowned_system.id.as(String)).organisation_id.should eq f.ucla.organisation.id + tenancy_get("#{Zones.base_route}#{f.unowned_zone.id}", f.ucla.admin).status_code.should eq 200 + end + end + end +end diff --git a/spec/helper.cr b/spec/helper.cr index 2f4b83f8..943fc363 100644 --- a/spec/helper.cr +++ b/spec/helper.cr @@ -94,6 +94,13 @@ def clear_tables PlaceOS::Model::Storage, PlaceOS::Model::DoorkeeperApplication, ].each(&.clear) + + # Tenancy rows go last: the estate tables above RESTRICT on organisations. + PlaceOS::Model::Edge.clear + PlaceOS::Model::Broker.clear + PlaceOS::Model::Grant.clear + PlaceOS::Model::Organisation.clear + PlaceOS::Model::Partner.clear end def random_name diff --git a/spec/migration/Dockerfile b/spec/migration/Dockerfile index 11531ab8..76b4070f 100644 --- a/spec/migration/Dockerfile +++ b/spec/migration/Dockerfile @@ -5,7 +5,8 @@ WORKDIR /app COPY . /app # NOTE:: add `--branch my-new-model-branch` if you want to test a particular branch -RUN git clone https://github.com/PlaceOS/models +# PPT-526: the tenancy migrations live on this branch until models #332 merges +RUN git clone --branch PPT-526-partner-client https://github.com/PlaceOS/models RUN mv ./models/migration/db ./db ENV PATH /app/bin:$PATH diff --git a/spec/spec_helpers/tenancy.cr b/spec/spec_helpers/tenancy.cr new file mode 100644 index 00000000..15e5922c --- /dev/null +++ b/spec/spec_helpers/tenancy.cr @@ -0,0 +1,146 @@ +require "placeos-models/spec/generator" + +module PlaceOS::Api::Spec::Tenancy + # One customer organisation with a domain, three users and a small estate + record Org, + organisation : Model::Organisation, + authority : Model::Authority, + admin : Model::User, + support : Model::User, + user : Model::User, + org_zone : Model::Zone, + building : Model::Zone, + system : Model::ControlSystem, + mod : Model::Module + + # The shape of a shared cluster: PlaceOS staff on localhost (cluster reach), + # NTT as a partner with its own staff organisation and one client (Acadian), + # UCLA as a direct customer, and an unowned zone tree. + record Fixture, + management : Model::Partner, + placeos : Org, + ntt : Model::Partner, + ntt_staff : Org, + acadian : Org, + ucla : Org, + unowned_zone : Model::Zone, + unowned_system : Model::ControlSystem + + def self.build : Fixture + management = Model::Generator.partner(management: true).save! + ntt = Model::Generator.partner.save! + + placeos = org("localhost", Model::Generator.organisation(partner: management, partner_staff: true).save!) + ntt_staff = org("ntt.test", Model::Generator.organisation(partner: ntt, partner_staff: true).save!) + acadian = org("acadian.test", Model::Generator.organisation(partner: ntt).save!) + ucla = org("ucla.test", Model::Generator.organisation.save!) + + unowned_zone = Model::Generator.zone.tap { |z| z.name = "Unowned #{random_name}"; z.tags = Set{"org"} }.save! + unowned_system = Model::Generator.control_system.tap do |sys| + sys.name = "Unowned room #{random_name}" + sys.zones = [unowned_zone.id.as(String)] + end.save! + + Fixture.new(management, placeos, ntt, ntt_staff, acadian, ucla, unowned_zone, unowned_system) + end + + def self.org(domain : String, organisation : Model::Organisation) : Org + authority = Model::Authority.find_by_domain(domain) || Model::Generator.authority(domain) + authority.domain = domain + authority.organisation_id = organisation.id + authority.save! + + org_zone = Model::Generator.zone.tap do |z| + z.name = "ORG #{organisation.name}" + z.tags = Set{"org"} + z.organisation_id = organisation.id + end.save! + authority.config_will_change! + authority.config["org_zone"] = JSON::Any.new(org_zone.id.as(String)) + authority.save! + + building = Model::Generator.zone.tap do |z| + z.name = "Building #{organisation.name}" + z.tags = Set{"building"} + z.parent_id = org_zone.id + z.organisation_id = organisation.id + end.save! + + system = Model::Generator.control_system.tap do |sys| + sys.name = "Room #{organisation.name}" + sys.zones = [org_zone.id.as(String), building.id.as(String)] + sys.organisation_id = organisation.id + end.save! + + mod = Model::Generator.module(control_system: system).tap(&.organisation_id = organisation.id).save! + system.modules = [mod.id.as(String)] + system.save! + + Org.new( + organisation: organisation, + authority: authority, + admin: Model::Generator.user(authority, admin: true).save!, + support: Model::Generator.user(authority, support: true).save!, + user: Model::Generator.user(authority).save!, + org_zone: org_zone, + building: building, + system: system, + mod: mod, + ) + end + + # Removes everything a fixture and its example created. The suite only + # clears tables once, so each example must leave the database as it found + # it: other specs list zones and users without a page size. + def self.teardown(f : Fixture) + none = [] of ::PgORM::Value + Model::Module.where("organisation_id IS NOT NULL", none).to_a.each { |row| row.destroy rescue nil } + Model::ControlSystem.where("organisation_id IS NOT NULL", none).to_a.each { |row| row.destroy rescue nil } + Model::Trigger.where("organisation_id IS NOT NULL OR name LIKE 'Template %'", none).to_a.each { |row| row.destroy rescue nil } + Model::Edge.where("organisation_id IS NOT NULL", none).to_a.each { |row| row.destroy rescue nil } + Model::Zone.where("organisation_id IS NOT NULL", none).to_a + .sort_by { |zone| zone.parent_id.presence ? 0 : 1 } + .each { |row| row.destroy rescue nil } + [f.unowned_system, f.unowned_zone].each { |row| row.destroy rescue nil } + + Model::Grant.clear + # fixture domains and anything an example created under them; localhost stays + Model::Authority.where("domain LIKE '%.test'", none).to_a.each { |row| row.destroy rescue nil } + [f.placeos.user, f.placeos.support, f.placeos.admin].each { |row| row.destroy rescue nil } + localhost = Model::Authority.find?(f.placeos.authority.id.as(String)) + if localhost + localhost.organisation_id = nil + localhost.save! + end + Model::Organisation.clear + Model::Partner.clear + end + + # Bearer + Host headers for a user on their own domain + def self.headers(user : Model::User) : HTTP::Headers + authority = user.authority.as(Model::Authority) + permissions = case {user.support, user.sys_admin} + when {true, true} then Model::UserJWT::Permissions::AdminSupport + when {true, false} then Model::UserJWT::Permissions::Support + when {false, true} then Model::UserJWT::Permissions::Admin + else Model::UserJWT::Permissions::User + end + jwt = Model::UserJWT.new( + iss: Model::UserJWT::ISSUER, + iat: 5.minutes.ago, + exp: 1.hour.from_now, + domain: authority.domain, + id: user.id.as(String), + user: Model::UserJWT::Metadata.new(name: user.name.as(String), email: user.email.to_s, permissions: permissions), + ) + HTTP::Headers{ + "Authorization" => "Bearer #{jwt.encode}", + "Content-Type" => "application/json", + "Host" => authority.domain, + } + end + + def self.ids(body : String) : Array(String) + Array(Hash(String, JSON::Any)).from_json(body).map(&.["id"].to_s) + end +end diff --git a/src/constants.cr b/src/constants.cr index 0f5b2d67..6abb3acc 100644 --- a/src/constants.cr +++ b/src/constants.cr @@ -9,6 +9,9 @@ module PlaceOS::Api BUILD_TIME = {{ system("date -u").stringify }} BUILD_COMMIT = {{ env("PLACE_COMMIT") || "DEV" }} + # PPT-526: refuse cross-organisation access (true) or only log what would be refused (false) + PLACE_TENANCY_ENFORCE = ENV["PLACE_TENANCY_ENFORCE"]?.try(&.downcase).in?("true", "1", "yes") || false + PLACE_DISPATCH_HOST = ENV["PLACE_DISPATCH_HOST"]? || "dispatch" PLACE_DISPATCH_PORT = (ENV["PLACE_DISPATCH_PORT"]? || "3000").to_i diff --git a/src/placeos-rest-api/controllers/api_keys.cr b/src/placeos-rest-api/controllers/api_keys.cr index 9c954258..c7ec765c 100644 --- a/src/placeos-rest-api/controllers/api_keys.cr +++ b/src/placeos-rest-api/controllers/api_keys.cr @@ -19,7 +19,9 @@ module PlaceOS::Api def find_current_api_key(id : String) Log.context.set(api_key: id) # Find will raise a 404 (not found) if there is an error - @current_api_key = ::PlaceOS::Model::ApiKey.find!(id) + api_key = ::PlaceOS::Model::ApiKey.find!(id) + ensure_authority_reach!(api_key.authority_id, "api key") + @current_api_key = api_key end getter! current_api_key : ::PlaceOS::Model::ApiKey @@ -33,9 +35,10 @@ module PlaceOS::Api authority_id : String? = nil, ) : Array(::PlaceOS::Model::ApiKey::PublicResponse) # PG full-text search (PPT-2644) - query = ::PlaceOS::Model::ApiKey.all + query = scope_authorities(::PlaceOS::Model::ApiKey.all) if authority = authority_id + ensure_authority_reach!(authority, "domain") query = query.where(authority_id: authority) end @@ -61,6 +64,9 @@ module PlaceOS::Api # create a new API key @[AC::Route::POST("/", body: :api_key, status_code: HTTP::Status::CREATED)] def create(api_key : ::PlaceOS::Model::ApiKey) : ::PlaceOS::Model::ApiKey::PublicResponse + if user_id = api_key.user_id + ensure_authority_reach!(::PlaceOS::Model::User.find!(user_id).authority_id, "user") + end raise Error::ModelValidation.new(api_key.errors) unless api_key.save api_key.to_public_struct end diff --git a/src/placeos-rest-api/controllers/application.cr b/src/placeos-rest-api/controllers/application.cr index cfaa8849..3dcec77b 100644 --- a/src/placeos-rest-api/controllers/application.cr +++ b/src/placeos-rest-api/controllers/application.cr @@ -38,6 +38,9 @@ module PlaceOS::Api # Helpers for defining scope checks on controller actions include Utils::Scopes + # Helpers for organisation reach (PPT-526 multi-tenancy) + include Utils::Tenancy + # for converting comma seperated lists # i.e. `"id-1,id-2,id-3"`, or a JSON array `["id-1","id-2"]` struct ConvertStringArray diff --git a/src/placeos-rest-api/controllers/authentications.cr b/src/placeos-rest-api/controllers/authentications.cr index c56a71ec..a7ac9e63 100644 --- a/src/placeos-rest-api/controllers/authentications.cr +++ b/src/placeos-rest-api/controllers/authentications.cr @@ -22,7 +22,9 @@ module PlaceOS::Api def find_current_auth(id : String) Log.context.set({{auth_type.id.underscore}}_id: id) # Find will raise a 404 (not found) if there is an error - @current_auth = ::PlaceOS::Model::{{auth_type.id}}Authentication.find!(id) + auth = ::PlaceOS::Model::{{auth_type.id}}Authentication.find!(id) + ensure_authority_reach!(auth.authority_id, "authentication") + @current_auth = auth end getter! current_auth : ::PlaceOS::Model::{{auth_type.id}}Authentication @@ -36,9 +38,10 @@ module PlaceOS::Api authority_id : String? = nil, ) : Array(::PlaceOS::Model::{{auth_type.id}}Authentication) # PG full-text search (PPT-2644) - query = ::PlaceOS::Model::{{auth_type.id}}Authentication.all + query = scope_authorities(::PlaceOS::Model::{{auth_type.id}}Authentication.all) if authority = authority_id + ensure_authority_reach!(authority, "domain") query = query.where(authority_id: authority) end @@ -64,6 +67,7 @@ module PlaceOS::Api # creates a new authentication method @[AC::Route::POST("/", body: :auth, status_code: HTTP::Status::CREATED)] def create(auth : ::PlaceOS::Model::{{auth_type.id}}Authentication) : ::PlaceOS::Model::{{auth_type.id}}Authentication + ensure_authority_reach!(auth.authority_id, "domain") raise Error::ModelValidation.new(auth.errors) unless auth.save auth end diff --git a/src/placeos-rest-api/controllers/brokers.cr b/src/placeos-rest-api/controllers/brokers.cr index 4ab9f934..f365197a 100644 --- a/src/placeos-rest-api/controllers/brokers.cr +++ b/src/placeos-rest-api/controllers/brokers.cr @@ -15,6 +15,8 @@ module PlaceOS::Api before_action :check_admin, except: [:collection, :show] before_action :check_support, only: [:collection, :show] + # brokers are cluster infrastructure + before_action :check_cluster_admin ############################################################################################### diff --git a/src/placeos-rest-api/controllers/build_monitor.cr b/src/placeos-rest-api/controllers/build_monitor.cr index cec327d5..706251ce 100644 --- a/src/placeos-rest-api/controllers/build_monitor.cr +++ b/src/placeos-rest-api/controllers/build_monitor.cr @@ -15,6 +15,7 @@ module PlaceOS::Api before_action :can_write, only: [:cancel] before_action :check_admin, only: [:cancel] + before_action :check_cluster_admin, only: [:cancel] ############################################################################################### diff --git a/src/placeos-rest-api/controllers/cluster.cr b/src/placeos-rest-api/controllers/cluster.cr index 28334958..dd135a23 100644 --- a/src/placeos-rest-api/controllers/cluster.cr +++ b/src/placeos-rest-api/controllers/cluster.cr @@ -12,6 +12,7 @@ module PlaceOS::Api ############################################################################################### before_action :check_admin + before_action :check_cluster_admin before_action :can_read, only: [:nodes, :show] before_action :can_write, only: [:destroy] diff --git a/src/placeos-rest-api/controllers/domains.cr b/src/placeos-rest-api/controllers/domains.cr index 6d7c0392..c5647e82 100644 --- a/src/placeos-rest-api/controllers/domains.cr +++ b/src/placeos-rest-api/controllers/domains.cr @@ -20,7 +20,9 @@ module PlaceOS::Api def find_current_domain(id : String) Log.context.set(authority_id: id) # Find will raise a 404 (not found) if there is an error - @current_domain = ::PlaceOS::Model::Authority.find!(id) + domain = ::PlaceOS::Model::Authority.find!(id) + ensure_reach!(domain) + @current_domain = domain end getter! current_domain : ::PlaceOS::Model::Authority @@ -29,9 +31,13 @@ module PlaceOS::Api # list the domains @[AC::Route::GET("/")] - def index : Array(::PlaceOS::Model::Authority) + def index( + @[AC::Param::Info(description: "only rows owned by this organisation (must be within reach)", example: "0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f")] + organisation_id : UUID? = nil, + ) : Array(::PlaceOS::Model::Authority) # PG full-text search (PPT-2644) - paginate_search(::PlaceOS::Model::Authority.all, ::PlaceOS::Model::Authority.table_name) + query = narrow_organisation(scope_organisations(::PlaceOS::Model::Authority.all), organisation_id) + paginate_search(query, ::PlaceOS::Model::Authority.table_name) end # skip authentication for the lookup @@ -59,16 +65,30 @@ module PlaceOS::Api # udpate a domains details @[AC::Route::PATCH("/:id", body: :domain)] @[AC::Route::PUT("/:id", body: :domain)] - def update(domain : ::PlaceOS::Model::Authority) : ::PlaceOS::Model::Authority + def update( + domain : ::PlaceOS::Model::Authority, + @[AC::Param::Info(description: "move the domain to this organisation (cluster admins only)", example: "0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f")] + organisation_id : UUID? = nil, + ) : ::PlaceOS::Model::Authority current = current_domain current.assign_attributes(domain) + if organisation_id + check_cluster_admin + current.organisation_id = organisation_id + end raise Error::ModelValidation.new(current.errors) unless current.save current end - # add a new domain + # add a new domain. Organisation admins create domains in their own + # organisation; cluster admins name the organisation. @[AC::Route::POST("/", body: :domain, status_code: HTTP::Status::CREATED)] - def create(domain : ::PlaceOS::Model::Authority) : ::PlaceOS::Model::Authority + def create( + domain : ::PlaceOS::Model::Authority, + @[AC::Param::Info(description: "the organisation that owns the new domain", example: "0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f")] + organisation_id : UUID? = nil, + ) : ::PlaceOS::Model::Authority + domain.organisation_id = organisation_for_new_row(organisation_id) raise Error::ModelValidation.new(domain.errors) unless domain.save domain end diff --git a/src/placeos-rest-api/controllers/drivers.cr b/src/placeos-rest-api/controllers/drivers.cr index f302fa1b..75ae0557 100644 --- a/src/placeos-rest-api/controllers/drivers.cr +++ b/src/placeos-rest-api/controllers/drivers.cr @@ -13,6 +13,8 @@ module PlaceOS::Api before_action :can_write, only: [:create, :update, :destroy, :remove] before_action :check_admin, except: [:index, :show, :readme] + # drivers are shared by every organisation; only cluster admins change them + before_action :check_cluster_admin, except: [:index, :show, :readme] # Restrict driver listing and readme access to admin/support. These # endpoints expose internal infrastructure detail (driver inventory, driver diff --git a/src/placeos-rest-api/controllers/edges.cr b/src/placeos-rest-api/controllers/edges.cr index 11a0f814..fc49cd07 100644 --- a/src/placeos-rest-api/controllers/edges.cr +++ b/src/placeos-rest-api/controllers/edges.cr @@ -34,9 +34,14 @@ module PlaceOS::Api def find_current_edge(id : String) Log.context.set(edge_id: id) # Find will raise a 404 (not found) if there is an error - @current_edge = ::PlaceOS::Model::Edge.find!(id) + edge = ::PlaceOS::Model::Edge.find!(id) + ensure_reach!(edge) + @current_edge = edge end + # Fleet-wide monitoring spans every organisation + before_action :check_cluster_admin, only: [:edges_health, :edges_errors, :edges_connections, :edges_module_failures, :edges_statistics, :edges_error_stream, :edges_module_stream, :cleanup_errors, :monitoring_summary] + getter! current_edge : ::PlaceOS::Model::Edge ############################################################################################### @@ -68,9 +73,13 @@ module PlaceOS::Api # list the edges in the system. # an edge can be thought of as a location and each edge location can have multiple nodes servicing it @[AC::Route::GET("/")] - def index : Array(::PlaceOS::Model::Edge) + def index( + @[AC::Param::Info(description: "only rows owned by this organisation (must be within reach)", example: "0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f")] + organisation_id : UUID? = nil, + ) : Array(::PlaceOS::Model::Edge) # PG full-text search (PPT-2644) - paginate_search(::PlaceOS::Model::Edge.all, ::PlaceOS::Model::Edge.table_name) + query = narrow_organisation(scope_organisations(::PlaceOS::Model::Edge.all), organisation_id) + paginate_search(query, ::PlaceOS::Model::Edge.table_name) end # return the details of an edge location @@ -93,11 +102,13 @@ module PlaceOS::Api @[AC::Route::POST("/", body: :create_body, status_code: HTTP::Status::CREATED)] def create(create_body : ::PlaceOS::Model::Edge::CreateBody) : ::PlaceOS::Model::Edge::KeyResponse user = ::PlaceOS::Model::User.find!(create_body.user_id || current_user.id.as(String)) + ensure_authority_reach!(user.authority_id, "user") new_edge = ::PlaceOS::Model::Edge.for_user( user: user, name: create_body.name, description: create_body.description ) + new_edge.organisation_id = ::PlaceOS::Model::Authority.find?(user.authority_id.as(String)).try(&.organisation_id) # Ensure instance variable initialised new_edge.x_api_key diff --git a/src/placeos-rest-api/controllers/grants.cr b/src/placeos-rest-api/controllers/grants.cr new file mode 100644 index 00000000..63be43d5 --- /dev/null +++ b/src/placeos-rest-api/controllers/grants.cr @@ -0,0 +1,105 @@ +require "placeos-models/grant" + +require "./application" + +module PlaceOS::Api + # Grants: explicit, revocable, optionally time-boxed reach for a user into a + # partner, organisation or domain they do not belong to (PPT-526) + class Grants < Application + base "/api/engine/v2/grants/" + + # Scopes + ############################################################################################### + + before_action :can_read, only: [:index, :show] + before_action :can_write, only: [:create, :destroy] + + before_action :check_admin + + ############################################################################################### + + @[AC::Route::Filter(:before_action, except: [:index, :create])] + def find_current_grant(id : UUID) + Log.context.set(grant_id: id.to_s) + grant = ::PlaceOS::Model::Grant.find!(id) + ensure_scope_reach!(grant.scope_type, grant.scope_id) + @current_grant = grant + end + + getter! current_grant : ::PlaceOS::Model::Grant + + # Cluster admins manage every scope; organisation admins manage grants + # into their own organisation and its domains; partner admins request + # rather than self-grant + protected def ensure_scope_reach!(scope_type : String, scope_id : String) : Nil + return if tenancy.cluster? + own = tenancy.organisation_id + allowed = case scope_type + when ::PlaceOS::Model::Grant::SCOPE_ORGANISATION + own && own.to_s == scope_id + when ::PlaceOS::Model::Grant::SCOPE_AUTHORITY + own && ::PlaceOS::Model::Authority.find?(scope_id).try(&.organisation_id) == own + else + false + end + return if allowed + log_would_refuse("grant scope outside reach") + raise Error::Forbidden.new if Utils::Tenancy.enforce? + end + + ############################################################################################### + + # grants on a scope, or held by a user + @[AC::Route::GET("/")] + def index( + @[AC::Param::Info(description: "partner, organisation or authority", example: "organisation")] + scope_type : String? = nil, + @[AC::Param::Info(description: "the id of the partner, organisation or domain", example: "0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f")] + scope_id : String? = nil, + @[AC::Param::Info(description: "grants held by this user", example: "user-1234")] + user_id : String? = nil, + ) : Array(::PlaceOS::Model::Grant) + query = ::PlaceOS::Model::Grant.all + if scope_type && scope_id + ensure_scope_reach!(scope_type, scope_id) + query = query.where(scope_type: scope_type, scope_id: scope_id) + elsif user_id + ensure_authority_reach!(::PlaceOS::Model::User.find!(user_id).authority_id, "user") + query = query.where(user_id: user_id) + else + check_cluster_admin + end + paginate_sql(query.order("created_at, id"), ::PlaceOS::Model::Grant.table_name, limit: search_limit, offset: search_offset) + end + + @[AC::Route::GET("/:id")] + def show : ::PlaceOS::Model::Grant + current_grant + end + + record GrantBody, user_id : String, scope_type : String, scope_id : String, permissions : Int32 = 1, expires_at : Time? = nil do + include JSON::Serializable + end + + @[AC::Route::POST("/", body: :body, status_code: HTTP::Status::CREATED)] + def create(body : GrantBody) : ::PlaceOS::Model::Grant + ensure_scope_reach!(body.scope_type, body.scope_id) + ::PlaceOS::Model::User.find!(body.user_id) + grant = ::PlaceOS::Model::Grant.new( + user_id: body.user_id, + scope_type: body.scope_type, + scope_id: body.scope_id, + permissions: body.permissions, + expires_at: body.expires_at, + granted_by: current_user.id, + ) + raise Error::ModelValidation.new(grant.errors) unless grant.save + grant + end + + @[AC::Route::DELETE("/:id", status_code: HTTP::Status::ACCEPTED)] + def destroy : Nil + current_grant.destroy + end + end +end diff --git a/src/placeos-rest-api/controllers/history.cr b/src/placeos-rest-api/controllers/history.cr index ed1609ef..94771a62 100644 --- a/src/placeos-rest-api/controllers/history.cr +++ b/src/placeos-rest-api/controllers/history.cr @@ -14,6 +14,8 @@ module PlaceOS::Api before_action :can_write, only: [:undelete] before_action :check_admin + # history spans every organisation's records + before_action :check_cluster_admin ############################################################################################### diff --git a/src/placeos-rest-api/controllers/map_ai.cr b/src/placeos-rest-api/controllers/map_ai.cr index a19c1e72..c7608775 100644 --- a/src/placeos-rest-api/controllers/map_ai.cr +++ b/src/placeos-rest-api/controllers/map_ai.cr @@ -197,7 +197,9 @@ module PlaceOS::Api raise Error::ModelValidation.new([Error::Field.new(:zone_id, "is required")]) if params.zone_id.blank? zone = ::PlaceOS::Model::Zone.find?(params.zone_id) - raise Error::NotFound.new("no such zone") if zone.nil? || !zone_visible?(zone) + raise Error::NotFound.new("no such zone") if zone.nil? + ensure_reach!(zone) + raise Error::NotFound.new("no such zone") unless zone_visible?(zone) raise Error::Forbidden.new("you cannot change this zone's map") unless zone_updatable?(zone) context = params.context diff --git a/src/placeos-rest-api/controllers/metadata.cr b/src/placeos-rest-api/controllers/metadata.cr index ea36900d..ce486a7f 100644 --- a/src/placeos-rest-api/controllers/metadata.cr +++ b/src/placeos-rest-api/controllers/metadata.cr @@ -26,10 +26,20 @@ module PlaceOS::Api @[AC::Param::Info(name: "id", description: "the parent id of the metadata to be destroyed")] parent_id : String, ) + ensure_parent_reach!(parent_id) unless parent_id == user_token.id return if user_support? || parent_id == user_token.id check_access_level(parent_id, admin_required: true) end + # Every parent-addressed read and write stays inside the caller's reach + @[AC::Route::Filter(:before_action, only: [:show, :children_metadata, :update, :merge, :rename, :history])] + def check_parent_reach( + @[AC::Param::Info(name: "id", description: "the parent id of the metadata")] + parent_id : String, + ) + ensure_parent_reach!(parent_id) unless parent_id == user_token.id + end + ############################################################################################### # Fetch metadata with a specific name for multiple resources @@ -54,6 +64,8 @@ module PlaceOS::Api # Build result hash with parent_id as key and metadata as value result = {} of String => ::PlaceOS::Model::Metadata::Interface? + parent_ids = parent_ids.select { |id| id == user_token.id || parent_in_reach?(id) } + parent_ids.each do |parent_id| metadata = ::PlaceOS::Model::Metadata.for(parent_id, metadata_name).first? result[parent_id] = metadata ? metadata.interface : nil diff --git a/src/placeos-rest-api/controllers/modules.cr b/src/placeos-rest-api/controllers/modules.cr index 677bee16..7c2b58ee 100644 --- a/src/placeos-rest-api/controllers/modules.cr +++ b/src/placeos-rest-api/controllers/modules.cr @@ -39,7 +39,9 @@ module PlaceOS::Api def find_current_module(id : String) Log.context.set(module_id: id) # Find will raise a 404 (not found) if there is an error - @current_module = ::PlaceOS::Model::Module.find!(id) + mod = ::PlaceOS::Model::Module.find!(id) + ensure_reach!(mod) + @current_module = mod end getter! current_module : ::PlaceOS::Model::Module @@ -111,7 +113,9 @@ module PlaceOS::Api org_zone = support_org_zone_id if control_system_id - zones = ::PlaceOS::Model::ControlSystem.find!(control_system_id).zones + system = ::PlaceOS::Model::ControlSystem.find!(control_system_id) + ensure_reach!(system) + zones = system.zones # "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 @@ -178,10 +182,13 @@ module PlaceOS::Api no_logic : Bool = false, @[AC::Param::Info(description: "return only running modules", example: "true")] running : Bool? = nil, + @[AC::Param::Info(description: "only rows owned by this organisation (must be within reach)", example: "0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f")] + organisation_id : UUID? = nil, ) : Array(::PlaceOS::Model::Module) # if a system id is present we query the database directly if control_system_id cs = ::PlaceOS::Model::ControlSystem.find!(control_system_id) + ensure_reach!(cs) # Include subset of association data with results results = ::PlaceOS::Model::Module.find_all(cs.modules).compact_map do |mod| next if (driver = mod.driver).nil? @@ -197,10 +204,8 @@ module PlaceOS::Api end # PG full-text search (PPT-2644) - query = ::PlaceOS::Model::Module.all + query = narrow_organisation(scope_organisations(::PlaceOS::Model::Module.all), organisation_id) - # TODO:: we can remove this once there is a tenant_id field on modules - # which will make this much simpler to filter if scope_zones = module_scope_zones # we only want to show modules in use by systems within these zones no_logic = true @@ -380,6 +385,8 @@ module PlaceOS::Api mod : ::PlaceOS::Model::Module, @[AC::Param::Info(description: "add the new module to this control system", example: "sys-1234")] system_id : String? = nil, + @[AC::Param::Info(description: "the organisation that owns a module not bound to a system (cluster admins only)", example: "0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f")] + organisation_id : UUID? = nil, ) : ::PlaceOS::Model::Module if system_id # only logic modules have a parent system, and they're added to it by an `after_create` callback @@ -391,6 +398,13 @@ module PlaceOS::Api end can_modify?(mod) + mod.organisation_id = if cs_id = mod.control_system_id + system = ::PlaceOS::Model::ControlSystem.find!(cs_id) + ensure_reach!(system) + system.organisation_id + else + organisation_for_new_row(organisation_id) + end raise Error::ModelValidation.new(mod.errors) unless mod.save mod end @@ -399,6 +413,9 @@ module PlaceOS::Api # adding it to the system doesn't leave an orphaned module behind. private def create_in_system(mod : ::PlaceOS::Model::Module, system_id : String) : ::PlaceOS::Model::Module sys = ::PlaceOS::Model::ControlSystem.find!(system_id) + # a new module belongs to the organisation of the system it's created in + ensure_reach!(sys) + mod.organisation_id = sys.organisation_id # the module isn't in any system yet, so the target system's zones are the only ones that apply. # Adding to the system requires the same access as `PUT /systems/:sys_id/module/:module_id` diff --git a/src/placeos-rest-api/controllers/oauth_applications.cr b/src/placeos-rest-api/controllers/oauth_applications.cr index 8e41ecdc..9cca240e 100644 --- a/src/placeos-rest-api/controllers/oauth_applications.cr +++ b/src/placeos-rest-api/controllers/oauth_applications.cr @@ -20,7 +20,9 @@ module PlaceOS::Api def find_current_app(id : String) Log.context.set(application_id: id) # Find will raise a 404 (not found) if there is an error - @current_app = ::PlaceOS::Model::DoorkeeperApplication.find!(id.to_i) + app = ::PlaceOS::Model::DoorkeeperApplication.find!(id.to_i) + ensure_authority_reach!(app.owner_id, "application") + @current_app = app end getter! current_app : ::PlaceOS::Model::DoorkeeperApplication @@ -38,7 +40,9 @@ module PlaceOS::Api if user_support? # admin/support: optional authority_id filter, no subsystem gating. + query = scope_authorities(query, "owner_id") if authority = authority_id + ensure_authority_reach!(authority, "domain") query = query.where(owner_id: authority) end else @@ -87,6 +91,7 @@ module PlaceOS::Api # add a new user interface application @[AC::Route::POST("/", body: :app, status_code: HTTP::Status::CREATED)] def create(app : ::PlaceOS::Model::DoorkeeperApplication) : ::PlaceOS::Model::DoorkeeperApplication + ensure_authority_reach!(app.owner_id, "domain") raise Error::ModelValidation.new(app.errors) unless app.save app end diff --git a/src/placeos-rest-api/controllers/organisations.cr b/src/placeos-rest-api/controllers/organisations.cr new file mode 100644 index 00000000..2a90b10c --- /dev/null +++ b/src/placeos-rest-api/controllers/organisations.cr @@ -0,0 +1,171 @@ +require "placeos-models/organisation" +require "placeos-models/partner" + +require "./application" + +module PlaceOS::Api + # Organisations: the customers that own domains and estate (PPT-526) + class Organisations < Application + base "/api/engine/v2/organisations/" + + # Scopes + ############################################################################################### + + before_action :can_read, only: [:index, :show, :current] + before_action :can_write, only: [:create, :update, :destroy, :claim] + + before_action :check_support, only: [:index, :show] + before_action :check_cluster_admin, only: [:create, :update, :destroy, :claim] + + ############################################################################################### + + @[AC::Route::Filter(:before_action, except: [:index, :create, :current])] + def find_current_organisation(id : UUID) + Log.context.set(organisation_id: id.to_s) + organisation = ::PlaceOS::Model::Organisation.find!(id) + ensure_reach!(organisation.id, "organisation") + @current_organisation_row = organisation + end + + getter! current_organisation_row : ::PlaceOS::Model::Organisation + + ############################################################################################### + + # What the caller can reach: the level, their own organisation and partner, + # and the organisations in reach. Backoffice reads this once at login. + record CurrentReach, + reach : String, + enforcing : Bool, + organisation : ::PlaceOS::Model::Organisation?, + partner : ::PlaceOS::Model::Partner?, + organisations : Array(::PlaceOS::Model::Organisation)? do + include JSON::Serializable + end + + @[AC::Route::GET("/current")] + def current : CurrentReach + scope = tenancy + organisations = scope.organisation_ids.try do |ids| + ids.empty? ? [] of ::PlaceOS::Model::Organisation : ::PlaceOS::Model::Organisation.find_all(ids.to_a).to_a + end + CurrentReach.new( + reach: scope.reach.to_s.downcase, + enforcing: Utils::Tenancy.enforce?, + organisation: scope.organisation, + partner: scope.partner, + organisations: organisations, + ) + end + + # list the organisations in reach + @[AC::Route::GET("/")] + def index( + @[AC::Param::Info(description: "only organisations under this partner", example: "0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f")] + partner_id : UUID? = nil, + ) : Array(::PlaceOS::Model::Organisation) + query = ::PlaceOS::Model::Organisation.all + query = query.where(partner_id: partner_id) if partner_id + ids = tenancy.organisation_ids + if ids + list = ids.to_a.map(&.to_s) + query = list.empty? ? query.where("1 = ?", 0) : query.where("id = ANY(ARRAY[#{list.join(", ") { "?" }}]::uuid[])", list) + end + paginate_search(query, ::PlaceOS::Model::Organisation.table_name) + end + + @[AC::Route::GET("/:id")] + def show : ::PlaceOS::Model::Organisation + current_organisation_row + end + + # `payer` and `partner_staff` are not mass-assignable, so they travel as + # parameters + @[AC::Route::POST("/", body: :organisation, status_code: HTTP::Status::CREATED)] + def create( + organisation : ::PlaceOS::Model::Organisation, + @[AC::Param::Info(description: "who is invoiced: partner or organisation", example: "partner")] + payer : String? = nil, + @[AC::Param::Info(description: "true for the partner's own staff organisation", example: "false")] + partner_staff : Bool? = nil, + ) : ::PlaceOS::Model::Organisation + organisation.payer = payer if payer + organisation.partner_staff = partner_staff unless partner_staff.nil? + raise Error::ModelValidation.new(organisation.errors) unless organisation.save + organisation + end + + @[AC::Route::PATCH("/:id", body: :organisation)] + @[AC::Route::PUT("/:id", body: :organisation)] + def update( + organisation : ::PlaceOS::Model::Organisation, + @[AC::Param::Info(description: "who is invoiced: partner or organisation", example: "partner")] + payer : String? = nil, + @[AC::Param::Info(description: "true for the partner's own staff organisation", example: "false")] + partner_staff : Bool? = nil, + ) : ::PlaceOS::Model::Organisation + current = current_organisation_row + current.assign_attributes(organisation) + current.payer = payer if payer + current.partner_staff = partner_staff unless partner_staff.nil? + raise Error::ModelValidation.new(current.errors) unless current.save + current + end + + # Deletes are RESTRICTed by the database while anything is owned + @[AC::Route::DELETE("/:id", status_code: HTTP::Status::ACCEPTED)] + def destroy : Nil + current_organisation_row.destroy + end + + record ClaimBody, zone_ids : Array(String) do + include JSON::Serializable + end + + record ClaimResult, zones : Int64, systems : Int64, modules : Int64, triggers : Int64 do + include JSON::Serializable + end + + # Assigns unowned zone trees to the organisation: the zones named, their + # descendants, the systems in those zones and the modules of those + # systems. Rows already owned by another organisation are left alone. + @[AC::Route::POST("/:id/claim", body: :body)] + def claim(body : ClaimBody) : ClaimResult + organisation_id = current_organisation_row.id.as(UUID).to_s + roots = body.zone_ids.uniq + return ClaimResult.new(0, 0, 0, 0) if roots.empty? + + PgORM::Database.connection do |db| + zones = db.exec(<<-SQL, args: [roots, organisation_id]).rows_affected + WITH RECURSIVE tree AS ( + SELECT id FROM zone WHERE id = ANY($1::text[]) + UNION + SELECT z.id FROM zone z INNER JOIN tree t ON z.parent_id = t.id + ) + UPDATE zone SET organisation_id = $2::uuid + WHERE id IN (SELECT id FROM tree) AND organisation_id IS NULL + SQL + systems = db.exec(<<-SQL, args: [organisation_id]).rows_affected + UPDATE sys SET organisation_id = $1::uuid + WHERE organisation_id IS NULL + AND EXISTS (SELECT 1 FROM zone z WHERE z.id = ANY(sys.zones) AND z.organisation_id = $1::uuid) + AND NOT EXISTS (SELECT 1 FROM zone z WHERE z.id = ANY(sys.zones) AND z.organisation_id IS NOT NULL AND z.organisation_id <> $1::uuid) + SQL + modules = db.exec(<<-SQL, args: [organisation_id]).rows_affected + UPDATE mod SET organisation_id = $1::uuid + WHERE organisation_id IS NULL + AND ( + control_system_id IN (SELECT id FROM sys WHERE organisation_id = $1::uuid) + OR EXISTS (SELECT 1 FROM sys s WHERE mod.id = ANY(s.modules) AND s.organisation_id = $1::uuid) + ) + AND NOT EXISTS (SELECT 1 FROM sys s WHERE mod.id = ANY(s.modules) AND s.organisation_id IS NOT NULL AND s.organisation_id <> $1::uuid) + SQL + triggers = db.exec(<<-SQL, args: [organisation_id]).rows_affected + UPDATE trigger SET organisation_id = $1::uuid + WHERE organisation_id IS NULL + AND control_system_id IN (SELECT id FROM sys WHERE organisation_id = $1::uuid) + SQL + ClaimResult.new(zones, systems, modules, triggers) + end + end + end +end diff --git a/src/placeos-rest-api/controllers/partners.cr b/src/placeos-rest-api/controllers/partners.cr new file mode 100644 index 00000000..8c4cce43 --- /dev/null +++ b/src/placeos-rest-api/controllers/partners.cr @@ -0,0 +1,74 @@ +require "placeos-models/partner" + +require "./application" + +module PlaceOS::Api + # Partners: integrators that bring in and manage organisations, and the + # management partner that is PlaceOS itself (PPT-526) + class Partners < Application + base "/api/engine/v2/partners/" + + # Scopes + ############################################################################################### + + before_action :can_read, only: [:index, :show] + before_action :can_write, only: [:create, :update, :destroy] + + before_action :check_support, only: [:index, :show] + before_action :check_cluster_admin, only: [:create, :update, :destroy] + + ############################################################################################### + + @[AC::Route::Filter(:before_action, except: [:index, :create])] + def find_current_partner(id : UUID) + Log.context.set(partner_id: id.to_s) + partner = ::PlaceOS::Model::Partner.find!(id) + unless tenancy.cluster? || tenancy.partner.try(&.id) == partner.id + log_would_refuse("partner outside reach") + raise Error::NotFound.new("partner not found") if Utils::Tenancy.enforce? + end + @current_partner_row = partner + end + + getter! current_partner_row : ::PlaceOS::Model::Partner + + ############################################################################################### + + # cluster admins list every partner; everyone else sees their own + @[AC::Route::GET("/")] + def index : Array(::PlaceOS::Model::Partner) + query = ::PlaceOS::Model::Partner.all + unless tenancy.cluster? + own = tenancy.partner.try(&.id) + query = own ? query.where(id: own) : query.where("1 = ?", 0) + end + paginate_search(query, ::PlaceOS::Model::Partner.table_name) + end + + @[AC::Route::GET("/:id")] + def show : ::PlaceOS::Model::Partner + current_partner_row + end + + @[AC::Route::POST("/", body: :partner, status_code: HTTP::Status::CREATED)] + def create(partner : ::PlaceOS::Model::Partner) : ::PlaceOS::Model::Partner + raise Error::ModelValidation.new(partner.errors) unless partner.save + partner + end + + @[AC::Route::PATCH("/:id", body: :partner)] + @[AC::Route::PUT("/:id", body: :partner)] + def update(partner : ::PlaceOS::Model::Partner) : ::PlaceOS::Model::Partner + current = current_partner_row + current.assign_attributes(partner) + raise Error::ModelValidation.new(current.errors) unless current.save + current + end + + # Deletes are RESTRICTed by the database while organisations remain + @[AC::Route::DELETE("/:id", status_code: HTTP::Status::ACCEPTED)] + def destroy : Nil + current_partner_row.destroy + end + end +end diff --git a/src/placeos-rest-api/controllers/repositories.cr b/src/placeos-rest-api/controllers/repositories.cr index 62f723e2..86bac348 100644 --- a/src/placeos-rest-api/controllers/repositories.cr +++ b/src/placeos-rest-api/controllers/repositories.cr @@ -16,6 +16,8 @@ module PlaceOS::Api before_action :check_admin, except: [:index, :show] before_action :check_support, only: [:index, :show] + # repositories are shared by every organisation; only cluster admins change them + before_action :check_cluster_admin, only: [:create, :update, :destroy, :remove, :pull] ############################################################################################### diff --git a/src/placeos-rest-api/controllers/root.cr b/src/placeos-rest-api/controllers/root.cr index 6359466f..d4216c64 100644 --- a/src/placeos-rest-api/controllers/root.cr +++ b/src/placeos-rest-api/controllers/root.cr @@ -23,6 +23,9 @@ module PlaceOS::Api before_action :can_write_guest, only: [:signal] + # resyncing source's MQTT state is cluster infrastructure + before_action :check_cluster_admin, only: [:source_resync, :source_resync_status] + # Healthcheck ############################################################################################### diff --git a/src/placeos-rest-api/controllers/schema.cr b/src/placeos-rest-api/controllers/schema.cr index 42b3b24b..f8fb2ffc 100644 --- a/src/placeos-rest-api/controllers/schema.cr +++ b/src/placeos-rest-api/controllers/schema.cr @@ -11,6 +11,7 @@ module PlaceOS::Api before_action :can_read, only: [:index, :show] before_action :check_admin, except: [:index, :show] + before_action :check_cluster_admin, except: [:index, :show] before_action :check_support, only: [:index, :show] ############################################################################################### diff --git a/src/placeos-rest-api/controllers/settings.cr b/src/placeos-rest-api/controllers/settings.cr index e14f96af..cd75b77a 100644 --- a/src/placeos-rest-api/controllers/settings.cr +++ b/src/placeos-rest-api/controllers/settings.cr @@ -32,6 +32,7 @@ module PlaceOS::Api ############################################################################################### def can_view?(parent_id) + ensure_parent_reach!(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) @@ -39,9 +40,12 @@ module PlaceOS::Api end def can_modify?(setting) + parent_id = setting.parent_id.as(String) + ensure_parent_reach!(parent_id) + # driver settings are shared by every organisation + check_cluster_admin if parent_id.starts_with?(::PlaceOS::Model::Driver.table_name) 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) @@ -102,6 +106,8 @@ module PlaceOS::Api parent_settings else raise Error::Forbidden.new unless user_support? + # the unscoped list spans every organisation + check_cluster_admin # PG full-text search (PPT-2644): `q` matches the settings keys (and # id) — the settings body itself is deliberately not searchable. diff --git a/src/placeos-rest-api/controllers/system-triggers.cr b/src/placeos-rest-api/controllers/system-triggers.cr index 40b0fdf8..b82f94ef 100644 --- a/src/placeos-rest-api/controllers/system-triggers.cr +++ b/src/placeos-rest-api/controllers/system-triggers.cr @@ -22,14 +22,18 @@ module PlaceOS::Api # the legacy org_zone path bypass as usual). @[AC::Route::Filter(:before_action, only: [:index, :show])] def check_trigger_read_permissions(sys_id : String) - zones = ::PlaceOS::Model::ControlSystem.find!(sys_id).zones + system = ::PlaceOS::Model::ControlSystem.find!(sys_id) + ensure_reach!(system) + zones = system.zones ensure_support_access!(zones, ::PlaceOS::Model::Permissions::Read) @secret_visible = secret_visible_on?(zones) end @[AC::Route::Filter(:before_action, only: [:create, :update, :destroy])] def check_trigger_write_permissions(sys_id : String) - zones = ::PlaceOS::Model::ControlSystem.find!(sys_id).zones + system = ::PlaceOS::Model::ControlSystem.find!(sys_id) + ensure_reach!(system) + zones = system.zones ensure_support_access!(zones, verb_permission) @secret_visible = secret_visible_on?(zones) end diff --git a/src/placeos-rest-api/controllers/systems.cr b/src/placeos-rest-api/controllers/systems.cr index 66879540..dfbc4216 100644 --- a/src/placeos-rest-api/controllers/systems.cr +++ b/src/placeos-rest-api/controllers/systems.cr @@ -38,15 +38,24 @@ module PlaceOS::Api ) Log.context.set(control_system_id: sys_id) # Find will raise a 404 (not found) if there is an error - if sys_id.includes?('@') - systems = find_by_email([sys_id]) - if systems.size > 0 - @current_control_system = systems.first - else - raise Error::NotFound.new("no system with email: #{sys_id}") - end - else - @current_control_system = ::PlaceOS::Model::ControlSystem.find!(sys_id) + system = if sys_id.includes?('@') + systems = find_by_email([sys_id]) + raise Error::NotFound.new("no system with email: #{sys_id}") if systems.empty? + systems.first + else + ::PlaceOS::Model::ControlSystem.find!(sys_id) + end + ensure_reach!(system) + @current_control_system = system + end + + # The lazily gated routes (execute, functions, state, state_lookup, types) + # take the system id without loading the row; a system that exists must + # be in reach. + @[AC::Route::Filter(:before_action, only: [:execute, :functions, :state, :state_lookup, :types, :metadata])] + def check_lazy_reach(sys_id : String) + if system = ::PlaceOS::Model::ControlSystem.find?(sys_id) + ensure_reach!(system) end end @@ -237,9 +246,11 @@ module PlaceOS::Api public : Bool? = nil, @[AC::Param::Info(description: "return systems which are signage", example: "true")] signage : Bool? = nil, + @[AC::Param::Info(description: "only rows owned by this organisation (must be within reach)", example: "0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f")] + organisation_id : UUID? = nil, ) : Array(::PlaceOS::Model::ControlSystem) # PG full-text search (PPT-2644) - query = ::PlaceOS::Model::ControlSystem.all + query = narrow_organisation(scope_organisations(::PlaceOS::Model::ControlSystem.all), organisation_id) # `zone_id` keeps its original AND semantics — a system must # contain *every* listed zone. The intended use is intersection @@ -344,7 +355,7 @@ module PlaceOS::Api @[AC::Param::Info(name: "in", description: "comma seperated list of emails", example: "room1@org.com,room2@org.com")] emails : Array(String), ) : Array(::PlaceOS::Model::ControlSystem) - systems = ::PlaceOS::Model::ControlSystem.where(email: emails.map(&.strip.downcase)).to_a + systems = scope_organisations(::PlaceOS::Model::ControlSystem.where(email: emails.map(&.strip.downcase))).to_a set_collection_headers(systems.size, ::PlaceOS::Model::ControlSystem.table_name) systems end @@ -405,14 +416,23 @@ module PlaceOS::Api current = current_control_system current.assign_attributes(updated) current.version = version + 1 + if updated.zones_assigned? + owner = organisation_for_zones(current.zones) + if owner && current.organisation_id && owner != current.organisation_id + raise Error::ModelValidation.new([Utils::Tenancy::Failure.new(:zones, "zones belong to another organisation")], "zones belong to another organisation") + end + current.organisation_id ||= owner + end raise Error::ModelValidation.new(current.errors) unless current.save current end - # adds a new system + # adds a new system. The system belongs to its zones' organisation, or + # the caller's when the zones are unowned. @[AC::Route::POST("/", status_code: HTTP::Status::CREATED)] def create : ::PlaceOS::Model::ControlSystem sys = control_system_update + sys.organisation_id = organisation_for_zones(sys.zones) raise Error::ModelValidation.new(sys.errors) unless sys.save sys end @@ -465,7 +485,14 @@ module PlaceOS::Api def add_module( module_id : String, ) : ::PlaceOS::Model::ControlSystem - raise Error::NotFound.new unless ::PlaceOS::Model::Module.exists?(module_id) + mod = ::PlaceOS::Model::Module.find?(module_id) + raise Error::NotFound.new unless mod + ensure_reach!(mod) + # a cluster admin attaching an unowned module adopts it into the system's organisation + if mod.organisation_id.nil? && (owner = current_control_system.organisation_id) + mod.organisation_id = owner + mod.save! + end module_present = current_control_system.modules.includes?(module_id) || ::PlaceOS::Model::ControlSystem.add_module(current_control_system.id.as(String), module_id) raise "Failed to add ControlSystem Module" unless module_present @@ -665,6 +692,7 @@ module PlaceOS::Api ws: ws, request_id: request_id, user: user_token, + organisation_ids: tenancy.organisation_ids, ) end diff --git a/src/placeos-rest-api/controllers/tenant_consent.cr b/src/placeos-rest-api/controllers/tenant_consent.cr index 2c56fac0..164955e0 100644 --- a/src/placeos-rest-api/controllers/tenant_consent.cr +++ b/src/placeos-rest-api/controllers/tenant_consent.cr @@ -35,6 +35,7 @@ module PlaceOS::Api @[AC::Route::GET("/:id")] def index(id : String) : NamedTuple(url: String) authority = ::PlaceOS::Model::Authority.find!(id) + ensure_reach!(authority) update_app_redirect_uri callback_url = URI.encode_www_form(redirect_url) # Not the authority id. `state` comes back from Microsoft through the diff --git a/src/placeos-rest-api/controllers/triggers.cr b/src/placeos-rest-api/controllers/triggers.cr index d6f61812..47d0190c 100644 --- a/src/placeos-rest-api/controllers/triggers.cr +++ b/src/placeos-rest-api/controllers/triggers.cr @@ -22,7 +22,17 @@ module PlaceOS::Api def find_current_trigger(id : String) Log.context.set(trigger_id: id) # Find will raise a 404 (not found) if there is an error - @current_trigger = ::PlaceOS::Model::Trigger.find!(id) + trigger = ::PlaceOS::Model::Trigger.find!(id) + # a trigger bound to a system follows that system; a definition with + # no system is a template any organisation may instantiate + ensure_reach!(trigger) if trigger.control_system_id + @current_trigger = trigger + end + + # Templates are cluster-managed + @[AC::Route::Filter(:before_action, only: [:update, :destroy])] + def check_template_write + check_cluster_admin if current_trigger.control_system_id.nil? end getter! current_trigger : ::PlaceOS::Model::Trigger @@ -31,9 +41,25 @@ module PlaceOS::Api # returns the list of available triggers @[AC::Route::GET("/")] - def index : Array(::PlaceOS::Model::Trigger) + def index( + @[AC::Param::Info(description: "only triggers bound to systems owned by this organisation; templates are always included", example: "0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f")] + organisation_id : UUID? = nil, + ) : Array(::PlaceOS::Model::Trigger) # PG full-text search (PPT-2644) - paginate_search(::PlaceOS::Model::Trigger.all, ::PlaceOS::Model::Trigger.table_name) + query = ::PlaceOS::Model::Trigger.all + if organisation_id + ensure_reach!(organisation_id, "organisation") + query = query.where("(control_system_id IS NULL OR organisation_id = ?::uuid)", organisation_id.to_s) + end + if ids = tenancy.organisation_ids + list = ids.to_a.map(&.to_s) + query = if list.empty? + query.where("control_system_id IS NULL", [] of String) + else + query.where("(control_system_id IS NULL OR organisation_id = ANY(ARRAY[#{list.join(", ") { "?" }}]::uuid[]))", list) + end + end + paginate_search(query, ::PlaceOS::Model::Trigger.table_name) end # update so we can provide instance details @@ -66,6 +92,13 @@ module PlaceOS::Api # adds a new trigger @[AC::Route::POST("/", body: :trig, status_code: HTTP::Status::CREATED)] def create(trig : ::PlaceOS::Model::Trigger) : ::PlaceOS::Model::Trigger + if cs_id = trig.control_system_id + system = ::PlaceOS::Model::ControlSystem.find!(cs_id) + ensure_reach!(system) + trig.organisation_id = system.organisation_id + else + check_cluster_admin + end raise Error::ModelValidation.new(trig.errors) unless trig.save trig end diff --git a/src/placeos-rest-api/controllers/users.cr b/src/placeos-rest-api/controllers/users.cr index b9aad311..43e2c9f8 100644 --- a/src/placeos-rest-api/controllers/users.cr +++ b/src/placeos-rest-api/controllers/users.cr @@ -65,9 +65,11 @@ module PlaceOS::Api raise PgORM::Error::RecordNotFound.new }.tap do |found| # The `:id` lookup above is not authority scoped — only admins may - # resolve users belonging to other authorities. 404 (not 403) so - # foreign ids are indistinguishable from unknown ones. + # resolve users belonging to other authorities, and only within their + # reach. 404 (not 403) so foreign ids are indistinguishable from + # unknown ones. raise PgORM::Error::RecordNotFound.new unless user_admin? || found.authority_id == authority + ensure_authority_reach!(found.authority_id, "user") unless found.authority_id == authority Log.context.set(user_id: found.id) @user = found end @@ -148,6 +150,8 @@ module PlaceOS::Api include_metadata : Bool = false, @[AC::Param::Info(description: "admin users can view other domains, ignored for other users", example: "auth-12345")] authority_id : String? = nil, + @[AC::Param::Info(description: "admin users can list the users of every domain owned by this organisation (must be within reach)", example: "0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f")] + organisation_id : UUID? = nil, ) : Array(UserDetails) # PG full-text search (PPT-2644) # NOTE: email-shaped queries used to be quote-wrapped for an @@ -162,7 +166,11 @@ module PlaceOS::Api # regular users can only see their own domain query = query.where(authority_id: current_user.authority_id.as(String)) elsif authority = authority_id + ensure_authority_reach!(authority, "domain") query = query.where(authority_id: authority) + else + # admins see every domain within reach, narrowed to one organisation on request + query = narrow_authorities(scope_authorities(query), organisation_id) end results = paginate_search(query, ::PlaceOS::Model::User.table_name) @@ -207,8 +215,9 @@ module PlaceOS::Api # only be set by admins — support-subsystem creators would otherwise # be able to mint sys-admins. new_user.assign_admin_attributes_from_json(body) - # allow sys-admins to create users on other domains + # allow sys-admins to create users on other domains within reach new_user.authority ||= current_authority.as(::PlaceOS::Model::Authority) + ensure_authority_reach!(new_user.authority_id, "domain") else # non-admins always create users in their own authority new_user.authority = current_authority.as(::PlaceOS::Model::Authority) @@ -247,12 +256,27 @@ module PlaceOS::Api user.deleted = true raise Error::ModelValidation.new(user.errors) unless user.save else + ensure_organisation_admin_remains!(user) user_id = user.id user.destroy spawn { Api::Metadata.signal_metadata(current_authority.not_nil!.id.to_s, :destroy_all, {parent_id: user_id}) } end end + # An organisation admin cannot remove their organisation's last admin. + # Cluster admins may, which is how a whole domain gets torn down. + protected def ensure_organisation_admin_remains!(target : ::PlaceOS::Model::User) : Nil + return unless target.sys_admin + return if tenancy.cluster? + organisation_id = ::PlaceOS::Model::Authority.find?(target.authority_id.as(String)).try(&.organisation_id) + return unless organisation_id + remaining = ::PlaceOS::Model::User + .where(sys_admin: true, deleted: false) + .where("authority_id IN (SELECT id FROM authority WHERE organisation_id = ?::uuid)", organisation_id.to_s) + .count + raise Error::Forbidden.new("at least one organisation admin must remain") if remaining <= 1 + end + # undelete a user @[AC::Route::POST("/:id/revive")] def revive diff --git a/src/placeos-rest-api/controllers/zones.cr b/src/placeos-rest-api/controllers/zones.cr index 6a0f71a4..a5f8d1b0 100644 --- a/src/placeos-rest-api/controllers/zones.cr +++ b/src/placeos-rest-api/controllers/zones.cr @@ -37,7 +37,9 @@ module PlaceOS::Api def current_zone(id : String) Log.context.set(zone_id: id) # Find will raise a 404 (not found) if there is an error - @current_zone = ::PlaceOS::Model::Zone.find!(id) + zone = ::PlaceOS::Model::Zone.find!(id) + ensure_reach!(zone) + @current_zone = zone end getter! current_zone : ::PlaceOS::Model::Zone @@ -154,6 +156,8 @@ module PlaceOS::Api include_children_count : Bool = false, @[AC::Param::Info(description: "with group_id, include anchor zones and their descendants, excluding denied subtrees", example: "true")] descendants : Bool = false, + @[AC::Param::Info(description: "only rows owned by this organisation (must be within reach)", example: "0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f")] + organisation_id : UUID? = nil, ) : Array(::PlaceOS::Model::Zone) group_zone_ids = nil if group_id @@ -179,7 +183,7 @@ module PlaceOS::Api # the same for `?parent_id=` as for no param at all parent_id = nil if parent_id.try(&.empty?) - query = ::PlaceOS::Model::Zone.all + query = narrow_organisation(scope_organisations(::PlaceOS::Model::Zone.all), organisation_id) if group_id && descendants query = query.where("id IN (#{group_descendants_sql})", group_id.to_s) @@ -269,8 +273,14 @@ module PlaceOS::Api # returns unique zone tags @[AC::Route::GET("/tags")] def tags : Array(String) + ids = tenancy.organisation_ids + return [] of String if ids && ids.empty? unique_tags = PgORM::Database.connection do |db| - db.query_one "select string_agg(distinct tag, ', ' order by tag) as types from ( select unnest(tags) as tag from zone ) as unnested", &.read(String) + if ids + db.query_one "select string_agg(distinct tag, ', ' order by tag) as types from ( select unnest(tags) as tag from zone where organisation_id = ANY($1::uuid[]) ) as unnested", args: [ids.to_a.map(&.to_s)], &.read(String) + else + db.query_one "select string_agg(distinct tag, ', ' order by tag) as types from ( select unnest(tags) as tag from zone ) as unnested", &.read(String) + end end unique_tags.split(',') rescue DB::ColumnTypeMismatchError @@ -296,14 +306,31 @@ module PlaceOS::Api zone = zone_update current = current_zone current.assign_attributes(zone) + if (parent_id = current.parent_id.presence) && current.parent_id_changed? + parent = ::PlaceOS::Model::Zone.find!(parent_id) + ensure_reach!(parent) + raise Error::ModelValidation.new([Utils::Tenancy::Failure.new(:parent_id, "parent belongs to another organisation")], "parent belongs to another organisation") unless parent.organisation_id == current.organisation_id + end raise Error::ModelValidation.new(current.errors) unless current.save current end - # add a new zone + # add a new zone. A child zone belongs to its parent's organisation; a + # root zone belongs to the caller's, or to the organisation a cluster + # admin names. @[AC::Route::POST("/", status_code: HTTP::Status::CREATED)] - def create : ::PlaceOS::Model::Zone + def create( + @[AC::Param::Info(description: "the organisation that owns a new root zone (cluster admins only)", example: "0192f1c4-7a6b-7c4d-9f3e-1a2b3c4d5e6f")] + organisation_id : UUID? = nil, + ) : ::PlaceOS::Model::Zone zone = zone_update + zone.organisation_id = if parent_id = zone.parent_id.presence + parent = ::PlaceOS::Model::Zone.find!(parent_id) + ensure_reach!(parent) + parent.organisation_id + else + organisation_for_new_row(organisation_id) + end raise Error::ModelValidation.new(zone.errors) unless zone.save zone end @@ -323,6 +350,7 @@ module PlaceOS::Api @[AC::Param::Info(description: "only return the metadata key we are interested in", example: "workplace-config")] name : String? = nil, ) : Hash(String, ::PlaceOS::Model::Metadata::Interface) + ensure_reach!(::PlaceOS::Model::Zone.find!(id)) ::PlaceOS::Model::Metadata.build_metadata(id, name) end diff --git a/src/placeos-rest-api/utilities/tenancy.cr b/src/placeos-rest-api/utilities/tenancy.cr new file mode 100644 index 00000000..70d76511 --- /dev/null +++ b/src/placeos-rest-api/utilities/tenancy.cr @@ -0,0 +1,283 @@ +require "uuid" +require "placeos-models/authority" +require "placeos-models/organisation" +require "placeos-models/partner" +require "placeos-models/grant" + +require "../../constants" + +module PlaceOS::Api + # Resolves, once per request, which organisations the caller may reach, and + # exposes the scope filter and reach checks the controllers apply. + # + # Reach is structural first and granted second: + # - cluster: an admin or support user whose domain's organisation belongs to + # the management partner. Sees every organisation, as before PPT-526. + # - partner: an admin or support user on a partner's staff organisation + # (`organisations.partner_staff`). Sees every organisation under that partner. + # - organisation: everyone else sees their own organisation only. + # Live grants widen the organisation set without changing the level. + # + # Rows with no organisation (unowned) are visible to cluster reach only. + # A caller whose domain has no organisation reaches nothing. + # + # `Tenancy.enforce?` (from `PLACE_TENANCY_ENFORCE`) switches between refusing + # and logging what would have been refused; the resolver runs either way. + module Utils::Tenancy + Log = ::Log.for("tenancy") + + class_property? enforce : Bool = PLACE_TENANCY_ENFORCE + + enum Reach + Cluster + Partner + Organisation + end + + # Shape `Error::ModelValidation` reads failures from + record Failure, field : Symbol, message : String + + # The resolved reach of the current request. `organisation_ids` is nil for + # cluster reach (no filter) and otherwise the exact set, possibly empty. + record Scope, + reach : Reach, + organisation_ids : Set(UUID)?, + organisation : ::PlaceOS::Model::Organisation?, + partner : ::PlaceOS::Model::Partner? do + def cluster? : Bool + reach.cluster? + end + + def includes?(organisation_id : UUID?) : Bool + return true if cluster? + return false if organisation_id.nil? + organisation_ids.as(Set(UUID)).includes?(organisation_id) + end + + def organisation_id : UUID? + organisation.try(&.id) + end + end + + getter current_organisation : ::PlaceOS::Model::Organisation? { current_authority.try(&.organisation) } + getter current_partner : ::PlaceOS::Model::Partner? { current_organisation.try(&.partner_id).try { |id| ::PlaceOS::Model::Partner.find?(id) } } + + # The caller's reach as it will be once enforcement is on. Controllers + # never read this directly for gating; see `tenancy`. + getter resolved_tenancy : Scope { resolve_tenancy } + + # The scope controllers apply. With enforcement off this is always cluster + # reach, so behaviour matches the release before PPT-526. + getter tenancy : Scope do + if Utils::Tenancy.enforce? + resolved_tenancy + else + Scope.new(Reach::Cluster, nil, current_organisation, current_partner) + end + end + + protected def resolve_tenancy : Scope + organisation = current_organisation + partner = current_partner + privileged = user_support? + + if organisation.nil? + Log.warn { {message: "domain has no organisation, caller reaches nothing", authority: current_authority.try(&.id), user_id: user_token.id} } + return Scope.new(Reach::Organisation, Set(UUID).new, nil, nil) + end + + organisation_id = organisation.id.as(UUID) + + if privileged && partner && partner.management + return Scope.new(Reach::Cluster, nil, organisation, partner) + end + + reach, ids = if privileged && partner && organisation.partner_staff + {Reach::Partner, organisation.partner_organisations.to_a.compact_map(&.id).to_set} + else + {Reach::Organisation, Set{organisation_id}} + end + + ids.concat(granted_organisation_ids) + Scope.new(reach, ids, organisation, partner) + end + + # Organisations the caller reaches through live grants that carry Read. + protected def granted_organisation_ids : Set(UUID) + ids = Set(UUID).new + return ids if user_token.guest_scope? + user_id = user_token.id + + ::PlaceOS::Model::Grant.for_user(user_id).each do |grant| + next unless grant.permission_flags.read? || grant.permission_flags.manage? + scope_id = grant.scope_id + case grant.scope_type + when ::PlaceOS::Model::Grant::SCOPE_ORGANISATION + UUID.parse?(scope_id).try { |id| ids << id } + when ::PlaceOS::Model::Grant::SCOPE_PARTNER + if partner_id = UUID.parse?(scope_id) + ::PlaceOS::Model::Organisation.where(partner_id: partner_id).each { |org| org.id.try { |id| ids << id } } + end + when ::PlaceOS::Model::Grant::SCOPE_AUTHORITY + ::PlaceOS::Model::Authority.find?(scope_id).try(&.organisation_id).try { |id| ids << id } + end + end + ids + end + + # ------------------------------------------------------------------ + # Gates + # ------------------------------------------------------------------ + + # Cluster-only routes (repositories, drivers, brokers, cluster, build jobs). + def check_cluster_admin + raise Error::Forbidden.new unless user_admin? + return if tenancy.cluster? + log_would_refuse("cluster admin required") + raise Error::Forbidden.new if Utils::Tenancy.enforce? + end + + # Raises 404 when `organisation_id` is outside the caller's reach, so a + # foreign id is indistinguishable from an unknown one. Unowned rows are in + # reach for cluster callers only. + def ensure_reach!(organisation_id : UUID?, resource : String = "resource") : Nil + return if tenancy.includes?(organisation_id) + log_would_refuse("#{resource} outside reach", organisation_id) + raise Error::NotFound.new("#{resource} not found") if Utils::Tenancy.enforce? + end + + def ensure_reach!(row : ::PlaceOS::Model::Authority | ::PlaceOS::Model::Zone | ::PlaceOS::Model::ControlSystem | ::PlaceOS::Model::Module | ::PlaceOS::Model::Trigger | ::PlaceOS::Model::Edge | ::PlaceOS::Model::Broker) : Nil + ensure_reach!(row.organisation_id, row.class.table_name) + end + + # Reach for a row owned through its domain (users, api keys, auth sources, + # OAuth applications). + def ensure_authority_reach!(authority_id : String?, resource : String = "resource") : Nil + return if tenancy.cluster? + organisation_id = authority_id.try { |id| ::PlaceOS::Model::Authority.find?(id).try(&.organisation_id) } + ensure_reach!(organisation_id, resource) + end + + # True when the caller may act on `organisation_id`; the quiet form of + # `ensure_reach!` for building filtered lists. + def in_reach?(organisation_id : UUID?) : Bool + tenancy.includes?(organisation_id) + end + + # ------------------------------------------------------------------ + # Query scoping + # ------------------------------------------------------------------ + + # Appends the reach filter to a relation on a table with an + # `organisation_id` column. No-op for cluster reach. + def scope_organisations(query, column : String = "organisation_id") + ids = tenancy.organisation_ids + return query if ids.nil? + if ids.empty? + query.where("1 = ?", 0) + else + list = ids.to_a.map(&.to_s) + query.where("#{column} = ANY(ARRAY[#{list.join(", ") { "?" }}]::uuid[])", list) + end + end + + # Narrows a reach-scoped relation to one organisation the caller asked for. + def narrow_organisation(query, organisation_id : UUID?, column : String = "organisation_id") + return query if organisation_id.nil? + ensure_reach!(organisation_id, "organisation") + query.where("#{column} = ?::uuid", organisation_id.to_s) + end + + # As above for a domain-owned table. + def narrow_authorities(query, organisation_id : UUID?, column : String = "authority_id") + return query if organisation_id.nil? + ensure_reach!(organisation_id, "organisation") + query.where("#{column} IN (SELECT id FROM authority WHERE organisation_id = ?::uuid)", organisation_id.to_s) + end + + # As above for tables owned through their domain (`authority_id`). + def scope_authorities(query, column : String = "authority_id") + ids = tenancy.organisation_ids + return query if ids.nil? + if ids.empty? + query.where("1 = ?", 0) + else + list = ids.to_a.map(&.to_s) + query.where("#{column} IN (SELECT id FROM authority WHERE organisation_id = ANY(ARRAY[#{list.join(", ") { "?" }}]::uuid[]))", list) + end + end + + # Domain ids in reach, for routes that take an `authority_id` parameter. + def reachable_authority_ids : Array(String)? + ids = tenancy.organisation_ids + return if ids.nil? + return [] of String if ids.empty? + list = ids.to_a.map(&.to_s) + ::PlaceOS::Model::Authority + .where("organisation_id = ANY(ARRAY[#{list.join(", ") { "?" }}]::uuid[])", list) + .to_a.compact_map(&.id) + end + + # The single organisation implied by a set of zone ids: every zone must be + # in reach and owned by the same organisation. Falls back to the caller's + # own organisation when the zones are unowned or there are none. + def organisation_for_zones(zone_ids : Array(String)) : UUID? + owners = Set(UUID).new + zone_ids.uniq.each do |zone_id| + zone = ::PlaceOS::Model::Zone.find!(zone_id) + ensure_reach!(zone) + zone.organisation_id.try { |id| owners << id } + end + raise Error::ModelValidation.new([Failure.new(:zones, "zones belong to more than one organisation")], "zones belong to more than one organisation") if owners.size > 1 + owners.first? || tenancy.organisation_id + end + + # Reach check for a settings/metadata parent id (zone-, sys-, mod-, user- + # or driver- prefixed). Drivers are global, so a driver parent is always + # in reach for reads. + def ensure_parent_reach!(parent_id : String) : Nil + case parent_id + when .starts_with?("zone-") then ensure_reach!(::PlaceOS::Model::Zone.find!(parent_id)) + when .starts_with?("sys-") then ensure_reach!(::PlaceOS::Model::ControlSystem.find!(parent_id)) + when .starts_with?("mod-") then ensure_reach!(::PlaceOS::Model::Module.find!(parent_id)) + when .starts_with?("user-") then ensure_authority_reach!(::PlaceOS::Model::User.find!(parent_id).authority_id, "user") + when .starts_with?("driver-") then nil + else ensure_reach!(nil, "parent") + end + end + + # Quiet form of `ensure_parent_reach!` for filtering id lists. + def parent_in_reach?(parent_id : String) : Bool + return true if tenancy.cluster? || !Utils::Tenancy.enforce? + ensure_parent_reach!(parent_id) + true + rescue Error::NotFound | PgORM::Error::RecordNotFound + false + end + + # The organisation a newly created estate row belongs to: the caller's own, + # or, for cluster callers, an explicit choice. + def organisation_for_new_row(requested : UUID? = nil) : UUID? + if requested + raise Error::Forbidden.new("organisation outside reach") unless in_reach?(requested) + return requested + end + tenancy.organisation_id + end + + protected def log_would_refuse(reason : String, organisation_id : UUID? = nil) + scope = resolved_tenancy + Log.warn do + { + message: Utils::Tenancy.enforce? ? "tenancy refused" : "tenancy would refuse", + reason: reason, + reach: scope.reach.to_s, + caller_org: scope.organisation_id.try(&.to_s), + target_org: organisation_id.try(&.to_s), + user_id: user_token.id, + path: request.path, + } + end + end + end +end diff --git a/src/placeos-rest-api/websocket/manager.cr b/src/placeos-rest-api/websocket/manager.cr index 1820bf0a..07a200ec 100644 --- a/src/placeos-rest-api/websocket/manager.cr +++ b/src/placeos-rest-api/websocket/manager.cr @@ -27,13 +27,14 @@ module PlaceOS::Api::WebSocket # Creates the session and handles the cleanup # - def create_session(ws, request_id, user) + def create_session(ws, request_id, user, organisation_ids : Set(UUID)? = nil) Log.trace { {request_id: request_id, frame: "OPEN"} } session = Session.new( ws: ws, request_id: request_id, user: user, discovery: discovery, + organisation_ids: organisation_ids, ) session_lock.synchronize { sessions << session } diff --git a/src/placeos-rest-api/websocket/session.cr b/src/placeos-rest-api/websocket/session.cr index dac001a2..727284dc 100644 --- a/src/placeos-rest-api/websocket/session.cr +++ b/src/placeos-rest-api/websocket/session.cr @@ -36,11 +36,15 @@ module PlaceOS::Api::WebSocket private getter user : ::PlaceOS::Model::UserJWT + # Organisations the session may touch; nil for cluster reach (PPT-526) + private getter organisation_ids : Set(UUID)? + def initialize( @ws : HTTP::WebSocket, @request_id : String, @user : ::PlaceOS::Model::UserJWT, @discovery : Clustering::Discovery = RemoteDriver.default_discovery, + @organisation_ids : Set(UUID)? = nil, ) # Register event handlers ws.on_message do |message| @@ -73,6 +77,18 @@ module PlaceOS::Api::WebSocket # WebSocket API Handlers ############################################################################## + # True when the system is inside the session's reach. Unknown systems are + # left to the existing lookups, which answer with the module-not-found + # error the clients already handle. + protected def system_in_reach?(system_id : String) : Bool + ids = organisation_ids + return true if ids.nil? + system = ::PlaceOS::Model::ControlSystem.find?(system_id) + return true if system.nil? + owner = system.organisation_id + !owner.nil? && ids.includes?(owner) + end + protected def elevate_security(system_id : String, default : Driver::Proxy::RemoteDriver::Clearance) : Driver::Proxy::RemoteDriver::Clearance return default if default.admin? @@ -527,6 +543,12 @@ module PlaceOS::Api::WebSocket } Log.context.set(**arguments.merge({ws_request_id: @request_id})) + unless system_in_reach?(request.system_id) + Log.warn { {message: "tenancy refused", reason: "system outside reach", system_id: request.system_id, user: @user.id} } + respond error_response(request.id, :module_not_found, "could not find module: sys=#{request.system_id} mod=#{request.module_name}") + return + end + case request.command in .bind? then bind(**arguments) in .unbind? then unbind(**arguments)