diff --git a/e2e/console/resource_tag_test.go b/e2e/console/resource_tag_test.go new file mode 100644 index 0000000000..22e479571f --- /dev/null +++ b/e2e/console/resource_tag_test.go @@ -0,0 +1,363 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package console_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestResourceTag_CatalogCRUD(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + const createMutation = ` + mutation($input: CreateResourceTagInput!) { + createResourceTag(input: $input) { + resourceTagEdge { + node { + id + key + value + color + } + } + } + } + ` + + var createResult struct { + CreateResourceTag struct { + ResourceTagEdge struct { + Node struct { + ID string `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + Color *string `json:"color"` + } `json:"node"` + } `json:"resourceTagEdge"` + } `json:"createResourceTag"` + } + + err := owner.Execute(createMutation, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "key": "environment", + "value": "Production", + "color": "#0f0", + }, + }, &createResult) + require.NoError(t, err) + assert.Equal(t, "environment", createResult.CreateResourceTag.ResourceTagEdge.Node.Key) + assert.Equal(t, "Production", createResult.CreateResourceTag.ResourceTagEdge.Node.Value) + require.NotNil(t, createResult.CreateResourceTag.ResourceTagEdge.Node.Color) + assert.Equal(t, "#0f0", *createResult.CreateResourceTag.ResourceTagEdge.Node.Color) + + tagID := createResult.CreateResourceTag.ResourceTagEdge.Node.ID + + const updateMutation = ` + mutation($input: UpdateResourceTagInput!) { + updateResourceTag(input: $input) { + resourceTag { + id + value + color + } + } + } + ` + + var updateResult struct { + UpdateResourceTag struct { + ResourceTag struct { + ID string `json:"id"` + Value string `json:"value"` + Color *string `json:"color"` + } `json:"resourceTag"` + } `json:"updateResourceTag"` + } + + err = owner.Execute(updateMutation, map[string]any{ + "input": map[string]any{ + "id": tagID, + "value": "Staging", + "color": "#00ff00", + }, + }, &updateResult) + require.NoError(t, err) + assert.Equal(t, "Staging", updateResult.UpdateResourceTag.ResourceTag.Value) + require.NotNil(t, updateResult.UpdateResourceTag.ResourceTag.Color) + assert.Equal(t, "#00ff00", *updateResult.UpdateResourceTag.ResourceTag.Color) + + const listQuery = ` + query($id: ID!) { + node(id: $id) { + ... on Organization { + resourceTags(first: 50) { + edges { + node { + id + key + } + } + totalCount + } + } + } + } + ` + + var listResult struct { + Node struct { + ResourceTags struct { + Edges []struct { + Node struct { + ID string `json:"id"` + Key string `json:"key"` + } `json:"node"` + } `json:"edges"` + TotalCount int `json:"totalCount"` + } `json:"resourceTags"` + } `json:"node"` + } + + err = owner.Execute(listQuery, map[string]any{ + "id": owner.GetOrganizationID().String(), + }, &listResult) + require.NoError(t, err) + require.GreaterOrEqual(t, listResult.Node.ResourceTags.TotalCount, 1) + + found := false + for _, edge := range listResult.Node.ResourceTags.Edges { + if edge.Node.ID == tagID { + found = true + assert.Equal(t, "environment", edge.Node.Key) + } + } + assert.True(t, found) + + const deleteMutation = ` + mutation($input: DeleteResourceTagInput!) { + deleteResourceTag(input: $input) { + deletedResourceTagId + } + } + ` + + var deleteResult struct { + DeleteResourceTag struct { + DeletedResourceTagID string `json:"deletedResourceTagId"` + } `json:"deleteResourceTag"` + } + + err = owner.Execute(deleteMutation, map[string]any{ + "input": map[string]any{ + "resourceTagId": tagID, + }, + }, &deleteResult) + require.NoError(t, err) + assert.Equal(t, tagID, deleteResult.DeleteResourceTag.DeletedResourceTagID) +} + +func TestResourceTag_AttachDetachAndConflict(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + documentID := factory.NewDocument(owner).WithTitle("Tagged Document").Create() + + const createMutation = ` + mutation($input: CreateResourceTagInput!) { + createResourceTag(input: $input) { + resourceTagEdge { node { id } } + } + } + ` + + var createResult struct { + CreateResourceTag struct { + ResourceTagEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"resourceTagEdge"` + } `json:"createResourceTag"` + } + + err := owner.Execute(createMutation, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "key": "department", + "value": "Security", + }, + }, &createResult) + require.NoError(t, err) + + tagID := createResult.CreateResourceTag.ResourceTagEdge.Node.ID + + const attachMutation = ` + mutation($input: AttachResourceTagInput!) { + attachResourceTag(input: $input) { + resourceId + tagId + } + } + ` + + err = owner.Execute(attachMutation, map[string]any{ + "input": map[string]any{ + "resourceId": documentID, + "tagId": tagID, + }, + }, nil) + require.NoError(t, err) + + _, err = owner.Do(attachMutation, map[string]any{ + "input": map[string]any{ + "resourceId": documentID, + "tagId": tagID, + }, + }) + require.Error(t, err) + + const forResourceQuery = ` + query($resourceId: ID!) { + resourceTagsForResource(resourceId: $resourceId) { + id + key + value + } + } + ` + + var forResourceResult struct { + ResourceTagsForResource []struct { + ID string `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + } `json:"resourceTagsForResource"` + } + + err = owner.Execute(forResourceQuery, map[string]any{ + "resourceId": documentID, + }, &forResourceResult) + require.NoError(t, err) + require.Len(t, forResourceResult.ResourceTagsForResource, 1) + assert.Equal(t, tagID, forResourceResult.ResourceTagsForResource[0].ID) + assert.Equal(t, "department", forResourceResult.ResourceTagsForResource[0].Key) + + const nodeQuery = ` + query($id: ID!) { + node(id: $id) { + ... on ResourceTag { + id + assignments(first: 50) { + edges { + node { + resourceId + } + } + totalCount + } + } + } + } + ` + + var nodeResult struct { + Node struct { + ID string `json:"id"` + Assignments struct { + Edges []struct { + Node struct { + ResourceID string `json:"resourceId"` + } `json:"node"` + } `json:"edges"` + TotalCount int `json:"totalCount"` + } `json:"assignments"` + } `json:"node"` + } + + err = owner.Execute(nodeQuery, map[string]any{"id": tagID}, &nodeResult) + require.NoError(t, err) + assert.Equal(t, 1, nodeResult.Node.Assignments.TotalCount) + require.Len(t, nodeResult.Node.Assignments.Edges, 1) + assert.Equal(t, documentID, nodeResult.Node.Assignments.Edges[0].Node.ResourceID) + + const detachMutation = ` + mutation($input: DetachResourceTagInput!) { + detachResourceTag(input: $input) { + resourceId + tagId + } + } + ` + + err = owner.Execute(detachMutation, map[string]any{ + "input": map[string]any{ + "resourceId": documentID, + "tagId": tagID, + }, + }, nil) + require.NoError(t, err) + + err = owner.Execute(forResourceQuery, map[string]any{ + "resourceId": documentID, + }, &forResourceResult) + require.NoError(t, err) + assert.Empty(t, forResourceResult.ResourceTagsForResource) +} + +func TestResourceTag_DuplicateKeyConflict(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + const createMutation = ` + mutation($input: CreateResourceTagInput!) { + createResourceTag(input: $input) { + resourceTagEdge { node { id } } + } + } + ` + + err := owner.Execute(createMutation, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "key": "duplicate-key", + "value": "One", + }, + }, nil) + require.NoError(t, err) + + _, err = owner.Do(createMutation, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "key": "duplicate-key", + "value": "Two", + }, + }) + require.Error(t, err) +} diff --git a/e2e/mcp/resource_tag_test.go b/e2e/mcp/resource_tag_test.go new file mode 100644 index 0000000000..0639fcc1ed --- /dev/null +++ b/e2e/mcp/resource_tag_test.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package mcp_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestMCP_ResourceTag(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + mc := testutil.NewMCPClient(t, owner) + documentID := factory.NewDocument(owner).WithTitle("MCP Tagged Document").Create() + orgID := owner.GetOrganizationID().String() + + var createOut struct { + ResourceTag struct { + ID string `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + Color *string `json:"color"` + } `json:"resource_tag"` + } + + mc.CallToolInto("createResourceTag", map[string]any{ + "organization_id": orgID, + "key": "mcp-env", + "value": "Production", + "color": "#abc", + }, &createOut) + assert.Equal(t, "mcp-env", createOut.ResourceTag.Key) + tagID := createOut.ResourceTag.ID + + mc.CallToolInto("attachResourceTag", map[string]any{ + "resource_id": documentID, + "tag_id": tagID, + }, nil) + + var forResourceOut struct { + ResourceTags []struct { + ID string `json:"id"` + Key string `json:"key"` + } `json:"resource_tags"` + } + + mc.CallToolInto("listResourceTagsForResource", map[string]any{ + "resource_id": documentID, + }, &forResourceOut) + require.Len(t, forResourceOut.ResourceTags, 1) + assert.Equal(t, tagID, forResourceOut.ResourceTags[0].ID) + + var listOut struct { + ResourceTags []struct { + ID string `json:"id"` + Key string `json:"key"` + } `json:"resource_tags"` + } + + mc.CallToolInto("listResourceTags", map[string]any{ + "organization_id": orgID, + }, &listOut) + + found := false + for _, tag := range listOut.ResourceTags { + if tag.ID == tagID { + found = true + } + } + assert.True(t, found) + + mc.CallToolInto("detachResourceTag", map[string]any{ + "resource_id": documentID, + "tag_id": tagID, + }, nil) + + mc.CallToolInto("deleteResourceTag", map[string]any{ + "resource_tag_id": tagID, + }, nil) +} diff --git a/packages/n8n-node/nodes/Probo/Probo.node.ts b/packages/n8n-node/nodes/Probo/Probo.node.ts index bc87e0ba5c..dd81b2a968 100644 --- a/packages/n8n-node/nodes/Probo/Probo.node.ts +++ b/packages/n8n-node/nodes/Probo/Probo.node.ts @@ -193,6 +193,11 @@ export class Probo implements INodeType { value: 'resourceAlias', description: 'Manage resource aliases', }, + { + name: 'Resource Tag', + value: 'resourceTag', + description: 'Manage resource tags', + }, { name: 'Rights Request', value: 'rightsRequest', diff --git a/packages/n8n-node/nodes/Probo/actions/index.ts b/packages/n8n-node/nodes/Probo/actions/index.ts index b86466f9d5..0b4be45d7b 100644 --- a/packages/n8n-node/nodes/Probo/actions/index.ts +++ b/packages/n8n-node/nodes/Probo/actions/index.ts @@ -43,6 +43,7 @@ import * as organizationContext from './organizationContext'; import * as processingActivity from './processingActivity'; import * as rightsRequest from './rightsRequest'; import * as resourceAlias from './resourceAlias'; +import * as resourceTag from './resourceTag'; import * as riskAssessment from './riskAssessment'; import * as user from './user'; import * as risk from './risk'; @@ -88,6 +89,7 @@ export const resources: Record = { processingActivity: processingActivity as ResourceModule, rightsRequest: rightsRequest as ResourceModule, resourceAlias: resourceAlias as ResourceModule, + resourceTag: resourceTag as ResourceModule, riskAssessment: riskAssessment as ResourceModule, user: user as ResourceModule, risk: risk as ResourceModule, diff --git a/packages/n8n-node/nodes/Probo/actions/resourceTag/attach.operation.ts b/packages/n8n-node/nodes/Probo/actions/resourceTag/attach.operation.ts new file mode 100644 index 0000000000..126e0b418d --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/resourceTag/attach.operation.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Resource ID', + name: 'resourceId', + type: 'string', + displayOptions: { + show: { + resource: ['resourceTag'], + operation: ['attach'], + }, + }, + default: '', + description: 'ID of the resource to tag', + required: true, + }, + { + displayName: 'Tag ID', + name: 'tagId', + type: 'string', + displayOptions: { + show: { + resource: ['resourceTag'], + operation: ['attach'], + }, + }, + default: '', + description: 'ID of the tag to attach', + required: true, + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const resourceId = this.getNodeParameter('resourceId', itemIndex) as string; + const tagId = this.getNodeParameter('tagId', itemIndex) as string; + + const query = ` + mutation AttachResourceTag($input: AttachResourceTagInput!) { + attachResourceTag(input: $input) { + resourceId + tagId + } + } + `; + + const responseData = await proboApiRequest.call(this, query, { + input: { resourceId, tagId }, + }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/packages/n8n-node/nodes/Probo/actions/resourceTag/create.operation.ts b/packages/n8n-node/nodes/Probo/actions/resourceTag/create.operation.ts new file mode 100644 index 0000000000..8465d3c03c --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/resourceTag/create.operation.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'string', + displayOptions: { + show: { + resource: ['resourceTag'], + operation: ['create'], + }, + }, + default: '', + description: 'The ID of the organization', + required: true, + }, + { + displayName: 'Key', + name: 'key', + type: 'string', + displayOptions: { + show: { + resource: ['resourceTag'], + operation: ['create'], + }, + }, + default: '', + description: 'Unique slug key for the tag within the organization', + required: true, + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + displayOptions: { + show: { + resource: ['resourceTag'], + operation: ['create'], + }, + }, + default: '', + description: 'Display value for the tag', + required: true, + }, + { + displayName: 'Color', + name: 'color', + type: 'string', + displayOptions: { + show: { + resource: ['resourceTag'], + operation: ['create'], + }, + }, + default: '', + description: 'Optional hex color (#RGB or #RRGGBB)', + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const organizationId = this.getNodeParameter('organizationId', itemIndex) as string; + const key = this.getNodeParameter('key', itemIndex) as string; + const value = this.getNodeParameter('value', itemIndex) as string; + const color = this.getNodeParameter('color', itemIndex, '') as string; + + const query = ` + mutation CreateResourceTag($input: CreateResourceTagInput!) { + createResourceTag(input: $input) { + resourceTagEdge { + node { + id + key + value + color + createdAt + updatedAt + } + } + } + } + `; + + const input: Record = { + organizationId, + key, + value, + }; + if (color) input.color = color; + + const responseData = await proboApiRequest.call(this, query, { input }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/packages/n8n-node/nodes/Probo/actions/resourceTag/delete.operation.ts b/packages/n8n-node/nodes/Probo/actions/resourceTag/delete.operation.ts new file mode 100644 index 0000000000..0a609c8073 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/resourceTag/delete.operation.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Resource Tag ID', + name: 'resourceTagId', + type: 'string', + displayOptions: { + show: { + resource: ['resourceTag'], + operation: ['delete'], + }, + }, + default: '', + description: 'The ID of the resource tag to delete', + required: true, + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const resourceTagId = this.getNodeParameter('resourceTagId', itemIndex) as string; + + const query = ` + mutation DeleteResourceTag($input: DeleteResourceTagInput!) { + deleteResourceTag(input: $input) { + deletedResourceTagId + } + } + `; + + const responseData = await proboApiRequest.call(this, query, { + input: { resourceTagId }, + }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/packages/n8n-node/nodes/Probo/actions/resourceTag/detach.operation.ts b/packages/n8n-node/nodes/Probo/actions/resourceTag/detach.operation.ts new file mode 100644 index 0000000000..0c7d6cf394 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/resourceTag/detach.operation.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Resource ID', + name: 'resourceId', + type: 'string', + displayOptions: { + show: { + resource: ['resourceTag'], + operation: ['detach'], + }, + }, + default: '', + description: 'ID of the resource to untag', + required: true, + }, + { + displayName: 'Tag ID', + name: 'tagId', + type: 'string', + displayOptions: { + show: { + resource: ['resourceTag'], + operation: ['detach'], + }, + }, + default: '', + description: 'ID of the tag to detach', + required: true, + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const resourceId = this.getNodeParameter('resourceId', itemIndex) as string; + const tagId = this.getNodeParameter('tagId', itemIndex) as string; + + const query = ` + mutation DetachResourceTag($input: DetachResourceTagInput!) { + detachResourceTag(input: $input) { + resourceId + tagId + } + } + `; + + const responseData = await proboApiRequest.call(this, query, { + input: { resourceId, tagId }, + }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/packages/n8n-node/nodes/Probo/actions/resourceTag/index.ts b/packages/n8n-node/nodes/Probo/actions/resourceTag/index.ts new file mode 100644 index 0000000000..b642db7fb8 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/resourceTag/index.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import type { INodeProperties } from 'n8n-workflow'; +import * as attachOp from './attach.operation'; +import * as createOp from './create.operation'; +import * as deleteOp from './delete.operation'; +import * as detachOp from './detach.operation'; +import * as listOp from './list.operation'; +import * as updateOp from './update.operation'; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['resourceTag'], + }, + }, + options: [ + { + name: 'Attach', + value: 'attach', + description: 'Attach a resource tag to a resource', + action: 'Attach a resource tag', + }, + { + name: 'Create', + value: 'create', + description: 'Create a new resource tag', + action: 'Create a resource tag', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a resource tag', + action: 'Delete a resource tag', + }, + { + name: 'Detach', + value: 'detach', + description: 'Detach a resource tag from a resource', + action: 'Detach a resource tag', + }, + { + name: 'List', + value: 'list', + description: 'List resource tags in an organization', + action: 'List resource tags', + }, + { + name: 'Update', + value: 'update', + description: 'Update a resource tag', + action: 'Update a resource tag', + }, + ], + default: 'create', + }, + ...attachOp.description, + ...createOp.description, + ...deleteOp.description, + ...detachOp.description, + ...listOp.description, + ...updateOp.description, +]; + +export { + attachOp as attach, + createOp as create, + deleteOp as delete, + detachOp as detach, + listOp as list, + updateOp as update, +}; diff --git a/packages/n8n-node/nodes/Probo/actions/resourceTag/list.operation.ts b/packages/n8n-node/nodes/Probo/actions/resourceTag/list.operation.ts new file mode 100644 index 0000000000..caba2a2dd9 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/resourceTag/list.operation.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow'; +import { proboApiRequestAllItems } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'string', + displayOptions: { + show: { + resource: ['resourceTag'], + operation: ['list'], + }, + }, + default: '', + description: 'The ID of the organization', + required: true, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: ['resourceTag'], + operation: ['list'], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['resourceTag'], + operation: ['list'], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + }, + default: 50, + description: 'Max number of results to return', + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const organizationId = this.getNodeParameter('organizationId', itemIndex) as string; + const returnAll = this.getNodeParameter('returnAll', itemIndex) as boolean; + const limit = this.getNodeParameter('limit', itemIndex, 50) as number; + + const query = ` + query ListResourceTags($organizationId: ID!, $first: Int, $after: CursorKey) { + node(id: $organizationId) { + ... on Organization { + resourceTags(first: $first, after: $after) { + edges { + node { + id + key + value + color + createdAt + updatedAt + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } + `; + + const resourceTags = await proboApiRequestAllItems.call( + this, + query, + { organizationId }, + (response) => { + const data = response?.data as IDataObject | undefined; + const node = data?.node as IDataObject | undefined; + return node?.resourceTags as IDataObject | undefined; + }, + returnAll, + limit, + ); + + return { + json: { resourceTags }, + pairedItem: { item: itemIndex }, + }; +} diff --git a/packages/n8n-node/nodes/Probo/actions/resourceTag/update.operation.ts b/packages/n8n-node/nodes/Probo/actions/resourceTag/update.operation.ts new file mode 100644 index 0000000000..6ce8abbfa0 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/resourceTag/update.operation.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Resource Tag ID', + name: 'id', + type: 'string', + displayOptions: { + show: { + resource: ['resourceTag'], + operation: ['update'], + }, + }, + default: '', + description: 'The ID of the resource tag to update', + required: true, + }, + { + displayName: 'Key', + name: 'key', + type: 'string', + displayOptions: { + show: { + resource: ['resourceTag'], + operation: ['update'], + }, + }, + default: '', + description: 'Unique slug key for the tag within the organization', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + displayOptions: { + show: { + resource: ['resourceTag'], + operation: ['update'], + }, + }, + default: '', + description: 'Display value for the tag', + }, + { + displayName: 'Color', + name: 'color', + type: 'string', + displayOptions: { + show: { + resource: ['resourceTag'], + operation: ['update'], + }, + }, + default: '', + description: 'Optional hex color (#RGB or #RRGGBB)', + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const id = this.getNodeParameter('id', itemIndex) as string; + const key = this.getNodeParameter('key', itemIndex, '') as string; + const value = this.getNodeParameter('value', itemIndex, '') as string; + const color = this.getNodeParameter('color', itemIndex, '') as string; + + const query = ` + mutation UpdateResourceTag($input: UpdateResourceTagInput!) { + updateResourceTag(input: $input) { + resourceTag { + id + key + value + color + createdAt + updatedAt + } + } + } + `; + + const input: Record = { id }; + if (key) input.key = key; + if (value) input.value = value; + if (color) input.color = color; + + const responseData = await proboApiRequest.call(this, query, { input }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/pkg/cmd/resource-tag/attach/attach.go b/pkg/cmd/resource-tag/attach/attach.go new file mode 100644 index 0000000000..3f2dbd5aeb --- /dev/null +++ b/pkg/cmd/resource-tag/attach/attach.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package attach + +import ( + "encoding/json" + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const attachMutation = ` +mutation($input: AttachResourceTagInput!) { + attachResourceTag(input: $input) { + resourceId + tagId + } +} +` + +type attachResponse struct { + AttachResourceTag struct { + ResourceID string `json:"resourceId"` + TagID string `json:"tagId"` + } `json:"attachResourceTag"` +} + +func NewCmdAttach(f *cmdutil.Factory) *cobra.Command { + var ( + flagResourceID string + flagTagID string + ) + + cmd := &cobra.Command{ + Use: "attach", + Short: "Attach a resource tag to a resource", + Example: ` # Attach a tag interactively + prb resource-tag attach --resource-id prbdoc_... + + # Attach a tag non-interactively + prb resource-tag attach --resource-id prbdoc_... --tag-id prbrtg_...`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + if f.IOStreams.IsInteractive() { + if flagResourceID == "" { + err := huh.NewInput(). + Title("Resource ID"). + Value(&flagResourceID). + Run() + if err != nil { + return err + } + } + + if flagTagID == "" { + err := huh.NewInput(). + Title("Tag ID"). + Value(&flagTagID). + Run() + if err != nil { + return err + } + } + } + + if flagResourceID == "" { + return fmt.Errorf("resource ID is required; pass --resource-id or run interactively") + } + + if flagTagID == "" { + return fmt.Errorf("tag ID is required; pass --tag-id or run interactively") + } + + data, err := client.Do( + attachMutation, + map[string]any{ + "input": map[string]any{ + "resourceId": flagResourceID, + "tagId": flagTagID, + }, + }, + ) + if err != nil { + return err + } + + var resp attachResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + a := resp.AttachResourceTag + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Attached tag %s to resource %s\n", + a.TagID, + a.ResourceID, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagResourceID, "resource-id", "", "ID of the resource to tag") + cmd.Flags().StringVar(&flagTagID, "tag-id", "", "ID of the tag to attach") + + return cmd +} diff --git a/pkg/cmd/resource-tag/create/create.go b/pkg/cmd/resource-tag/create/create.go new file mode 100644 index 0000000000..8c8381dd7f --- /dev/null +++ b/pkg/cmd/resource-tag/create/create.go @@ -0,0 +1,196 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package create + +import ( + "encoding/json" + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const createMutation = ` +mutation($input: CreateResourceTagInput!) { + createResourceTag(input: $input) { + resourceTagEdge { + node { + id + key + value + color + } + } + } +} +` + +type createResponse struct { + CreateResourceTag struct { + ResourceTagEdge struct { + Node struct { + ID string `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + Color *string `json:"color"` + } `json:"node"` + } `json:"resourceTagEdge"` + } `json:"createResourceTag"` +} + +func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagKey string + flagValue string + flagColor string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a resource tag", + Example: ` # Create a tag interactively + prb resource-tag create + + # Create a tag non-interactively + prb resource-tag create --organization-id prborg_... --key environment --value production --color "#FF0000"`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if f.IOStreams.IsInteractive() { + if flagOrg == "" { + err := huh.NewInput(). + Title("Organization ID"). + Value(&flagOrg). + Run() + if err != nil { + return err + } + } + + if flagKey == "" { + err := huh.NewInput(). + Title("Key"). + Value(&flagKey). + Run() + if err != nil { + return err + } + } + + if flagValue == "" { + err := huh.NewInput(). + Title("Value"). + Value(&flagValue). + Run() + if err != nil { + return err + } + } + + if flagColor == "" { + err := huh.NewInput(). + Title("Color (optional, hex)"). + Value(&flagColor). + Run() + if err != nil { + return err + } + } + } + + if flagOrg == "" { + return fmt.Errorf("organization ID is required; pass --organization-id or set a default with 'prb auth login'") + } + + if flagKey == "" { + return fmt.Errorf("key is required; pass --key or run interactively") + } + + if flagValue == "" { + return fmt.Errorf("value is required; pass --value or run interactively") + } + + input := map[string]any{ + "organizationId": flagOrg, + "key": flagKey, + "value": flagValue, + } + + if flagColor != "" { + input["color"] = flagColor + } + + data, err := client.Do( + createMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp createResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + t := resp.CreateResourceTag.ResourceTagEdge.Node + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Created resource tag %s (%s=%s)\n", + t.ID, + t.Key, + t.Value, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "organization-id", "", "Organization ID") + cmd.Flags().StringVar(&flagKey, "key", "", "Unique slug key for the tag") + cmd.Flags().StringVar(&flagValue, "value", "", "Display value for the tag") + cmd.Flags().StringVar(&flagColor, "color", "", "Optional hex color (#RGB or #RRGGBB)") + + return cmd +} diff --git a/pkg/cmd/resource-tag/delete/delete.go b/pkg/cmd/resource-tag/delete/delete.go new file mode 100644 index 0000000000..1ad7b0d07a --- /dev/null +++ b/pkg/cmd/resource-tag/delete/delete.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package delete + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const deleteMutation = ` +mutation($input: DeleteResourceTagInput!) { + deleteResourceTag(input: $input) { + deletedResourceTagId + } +} +` + +func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { + var ( + flagID string + flagResourceTagID string + flagYes bool + ) + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a resource tag", + Example: ` # Delete a tag interactively + prb resource-tag delete --id prbrtg_... + + # Delete without confirmation + prb resource-tag delete --resource-tag-id prbrtg_... --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + id := flagID + if id == "" { + id = flagResourceTagID + } + + if f.IOStreams.IsInteractive() { + if id == "" { + err := huh.NewInput(). + Title("Resource tag ID"). + Value(&id). + Run() + if err != nil { + return err + } + } + } + + if id == "" { + return fmt.Errorf("resource tag ID is required; pass --id or --resource-tag-id or run interactively") + } + + if !flagYes { + if !f.IOStreams.IsInteractive() { + return fmt.Errorf("cannot delete resource tag: confirmation required, use --yes to confirm") + } + + var confirmed bool + + err := huh.NewConfirm(). + Title(fmt.Sprintf("Delete resource tag %s?", id)). + Value(&confirmed). + Run() + if err != nil { + return err + } + + if !confirmed { + return nil + } + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + _, err = client.Do( + deleteMutation, + map[string]any{ + "input": map[string]any{ + "resourceTagId": id, + }, + }, + ) + if err != nil { + return err + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Deleted resource tag %s\n", + id, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagID, "id", "", "Resource tag ID") + cmd.Flags().StringVar(&flagResourceTagID, "resource-tag-id", "", "Resource tag ID") + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} diff --git a/pkg/cmd/resource-tag/detach/detach.go b/pkg/cmd/resource-tag/detach/detach.go new file mode 100644 index 0000000000..1881fd0c1b --- /dev/null +++ b/pkg/cmd/resource-tag/detach/detach.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package detach + +import ( + "encoding/json" + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const detachMutation = ` +mutation($input: DetachResourceTagInput!) { + detachResourceTag(input: $input) { + resourceId + tagId + } +} +` + +type detachResponse struct { + DetachResourceTag struct { + ResourceID string `json:"resourceId"` + TagID string `json:"tagId"` + } `json:"detachResourceTag"` +} + +func NewCmdDetach(f *cmdutil.Factory) *cobra.Command { + var ( + flagResourceID string + flagTagID string + ) + + cmd := &cobra.Command{ + Use: "detach", + Short: "Detach a resource tag from a resource", + Example: ` # Detach a tag interactively + prb resource-tag detach --resource-id prbdoc_... + + # Detach a tag non-interactively + prb resource-tag detach --resource-id prbdoc_... --tag-id prbrtg_...`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + if f.IOStreams.IsInteractive() { + if flagResourceID == "" { + err := huh.NewInput(). + Title("Resource ID"). + Value(&flagResourceID). + Run() + if err != nil { + return err + } + } + + if flagTagID == "" { + err := huh.NewInput(). + Title("Tag ID"). + Value(&flagTagID). + Run() + if err != nil { + return err + } + } + } + + if flagResourceID == "" { + return fmt.Errorf("resource ID is required; pass --resource-id or run interactively") + } + + if flagTagID == "" { + return fmt.Errorf("tag ID is required; pass --tag-id or run interactively") + } + + data, err := client.Do( + detachMutation, + map[string]any{ + "input": map[string]any{ + "resourceId": flagResourceID, + "tagId": flagTagID, + }, + }, + ) + if err != nil { + return err + } + + var resp detachResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + d := resp.DetachResourceTag + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Detached tag %s from resource %s\n", + d.TagID, + d.ResourceID, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagResourceID, "resource-id", "", "ID of the resource to untag") + cmd.Flags().StringVar(&flagTagID, "tag-id", "", "ID of the tag to detach") + + return cmd +} diff --git a/pkg/cmd/resource-tag/list/list.go b/pkg/cmd/resource-tag/list/list.go new file mode 100644 index 0000000000..37782b1b79 --- /dev/null +++ b/pkg/cmd/resource-tag/list/list.go @@ -0,0 +1,224 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package list + +import ( + "encoding/json" + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const listQuery = ` +query($id: ID!, $first: Int, $after: CursorKey, $orderBy: ResourceTagOrder) { + node(id: $id) { + __typename + ... on Organization { + resourceTags(first: $first, after: $after, orderBy: $orderBy) { + totalCount + edges { + node { + id + key + value + color + createdAt + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +` + +type resourceTag struct { + ID string `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + Color *string `json:"color"` + CreatedAt string `json:"createdAt"` +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagLimit int + flagOrderBy string + flagOrderDir string + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List resource tags in an organization", + Aliases: []string{"ls"}, + Example: ` # List tags in the default organization + prb resource-tag list + + # List tags for a specific organization + prb resource-tag list --organization-id prborg_... --json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if f.IOStreams.IsInteractive() { + if flagOrg == "" { + err := huh.NewInput(). + Title("Organization ID"). + Value(&flagOrg). + Run() + if err != nil { + return err + } + } + } + + if flagOrg == "" { + return fmt.Errorf("organization ID is required; pass --organization-id or set a default with 'prb auth login'") + } + + variables := map[string]any{ + "id": flagOrg, + } + + if flagOrderBy != "" { + if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"CREATED_AT", "KEY"}); err != nil { + return err + } + + variables["orderBy"] = map[string]any{ + "field": flagOrderBy, + "direction": flagOrderDir, + } + } + + tags, totalCount, err := api.Paginate( + client, + listQuery, + variables, + flagLimit, + func(data json.RawMessage) (*api.Connection[resourceTag], error) { + var resp struct { + Node *struct { + Typename string `json:"__typename"` + ResourceTags api.Connection[resourceTag] `json:"resourceTags"` + } `json:"node"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + + if resp.Node == nil { + return nil, fmt.Errorf("organization %s not found", flagOrg) + } + + return &resp.Node.ResourceTags, nil + }, + ) + if err != nil { + return err + } + + if *flagOutput == cmdutil.OutputJSON { + if tags == nil { + tags = []resourceTag{} + } + + return cmdutil.PrintJSON(f.IOStreams.Out, tags) + } + + if len(tags) == 0 { + _, _ = fmt.Fprintln(f.IOStreams.Out, "No resource tags found.") + return nil + } + + rows := make([][]string, 0, len(tags)) + for _, t := range tags { + color := "" + if t.Color != nil { + color = *t.Color + } + + rows = append(rows, []string{ + t.ID, + t.Key, + t.Value, + color, + cmdutil.FormatTime(t.CreatedAt), + }) + } + + table := cmdutil.NewTable("ID", "KEY", "VALUE", "COLOR", "CREATED").Rows(rows...) + + _, _ = fmt.Fprintln(f.IOStreams.Out, table) + + if totalCount > len(tags) { + _, _ = fmt.Fprintf( + f.IOStreams.ErrOut, + "\nShowing %d of %d resource tags\n", + len(tags), + totalCount, + ) + } + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "organization-id", "", "Organization ID") + cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of resource tags to list") + cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT, KEY)") + cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)") + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/resource-tag/resource_tag.go b/pkg/cmd/resource-tag/resource_tag.go new file mode 100644 index 0000000000..29c882cea4 --- /dev/null +++ b/pkg/cmd/resource-tag/resource_tag.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package resourcetag + +import ( + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/resource-tag/attach" + "go.probo.inc/probo/pkg/cmd/resource-tag/create" + "go.probo.inc/probo/pkg/cmd/resource-tag/delete" + "go.probo.inc/probo/pkg/cmd/resource-tag/detach" + "go.probo.inc/probo/pkg/cmd/resource-tag/list" + "go.probo.inc/probo/pkg/cmd/resource-tag/update" +) + +func NewCmdResourceTag(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "resource-tag ", + Short: "Manage resource tags", + Aliases: []string{"rt"}, + } + + cmd.AddCommand(list.NewCmdList(f)) + cmd.AddCommand(create.NewCmdCreate(f)) + cmd.AddCommand(update.NewCmdUpdate(f)) + cmd.AddCommand(delete.NewCmdDelete(f)) + cmd.AddCommand(attach.NewCmdAttach(f)) + cmd.AddCommand(detach.NewCmdDetach(f)) + + return cmd +} diff --git a/pkg/cmd/resource-tag/update/update.go b/pkg/cmd/resource-tag/update/update.go new file mode 100644 index 0000000000..03aa556ae5 --- /dev/null +++ b/pkg/cmd/resource-tag/update/update.go @@ -0,0 +1,160 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package update + +import ( + "encoding/json" + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const updateMutation = ` +mutation($input: UpdateResourceTagInput!) { + updateResourceTag(input: $input) { + resourceTag { + id + key + value + color + } + } +} +` + +type updateResponse struct { + UpdateResourceTag struct { + ResourceTag struct { + ID string `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + Color *string `json:"color"` + } `json:"resourceTag"` + } `json:"updateResourceTag"` +} + +func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { + var ( + flagID string + flagKey string + flagValue string + flagColor string + ) + + cmd := &cobra.Command{ + Use: "update", + Short: "Update a resource tag", + Example: ` # Update a tag interactively + prb resource-tag update --id prbrtg_... + + # Update key and value + prb resource-tag update --id prbrtg_... --key environment --value staging`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + if f.IOStreams.IsInteractive() { + if flagID == "" { + err := huh.NewInput(). + Title("Resource tag ID"). + Value(&flagID). + Run() + if err != nil { + return err + } + } + } + + if flagID == "" { + return fmt.Errorf("resource tag ID is required; pass --id or run interactively") + } + + input := map[string]any{ + "id": flagID, + } + + if cmd.Flags().Changed("key") { + input["key"] = flagKey + } + + if cmd.Flags().Changed("value") { + input["value"] = flagValue + } + + if cmd.Flags().Changed("color") { + input["color"] = flagColor + } + + if len(input) == 1 { + return fmt.Errorf("at least one of --key, --value, or --color must be specified") + } + + data, err := client.Do( + updateMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp updateResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + t := resp.UpdateResourceTag.ResourceTag + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Updated resource tag %s (%s=%s)\n", + t.ID, + t.Key, + t.Value, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagID, "id", "", "Resource tag ID") + cmd.Flags().StringVar(&flagKey, "key", "", "Unique slug key for the tag") + cmd.Flags().StringVar(&flagValue, "value", "", "Display value for the tag") + cmd.Flags().StringVar(&flagColor, "color", "", "Optional hex color (#RGB or #RRGGBB)") + + return cmd +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index e0d775769c..24d4401417 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -50,6 +50,7 @@ import ( "go.probo.inc/probo/pkg/cmd/org" processingactivity "go.probo.inc/probo/pkg/cmd/processing-activity" resourcealias "go.probo.inc/probo/pkg/cmd/resource-alias" + resourcetag "go.probo.inc/probo/pkg/cmd/resource-tag" rightsrequest "go.probo.inc/probo/pkg/cmd/rights-request" "go.probo.inc/probo/pkg/cmd/risk" riskassessment "go.probo.inc/probo/pkg/cmd/risk-assessment" @@ -128,6 +129,7 @@ func NewCmdRoot(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(risk.NewCmdRisk(f)) cmd.AddCommand(riskassessment.NewCmdRiskAssessment(f)) cmd.AddCommand(resourcealias.NewCmdResourceAlias(f)) + cmd.AddCommand(resourcetag.NewCmdResourceTag(f)) cmd.AddCommand(scim.NewCmdScim(f)) cmd.AddCommand(soa.NewCmdSoa(f)) cmd.AddCommand(task.NewCmdTask(f)) diff --git a/pkg/coredata/entity_type_reg.go b/pkg/coredata/entity_type_reg.go index 287f089ac5..1700ea261a 100644 --- a/pkg/coredata/entity_type_reg.go +++ b/pkg/coredata/entity_type_reg.go @@ -140,6 +140,8 @@ const ( DevicePostureEntityType uint16 = 108 DeviceEnrollmentTokenEntityType uint16 = 109 DevicePostureReportEntityType uint16 = 110 + ResourceTagEntityType uint16 = 111 + ResourceTagAssignmentEntityType uint16 = 112 ) func NewEntityFromID(id gid.GID) (any, bool) { @@ -348,6 +350,10 @@ func NewEntityFromID(id gid.GID) (any, bool) { return &DeviceEnrollmentToken{ID: id}, true case DevicePostureReportEntityType: return &DevicePostureReport{ID: id}, true + case ResourceTagEntityType: + return &ResourceTag{ID: id}, true + case ResourceTagAssignmentEntityType: + return &ResourceTagAssignment{ID: id}, true default: return nil, false } diff --git a/pkg/coredata/migrations/20260731T104622Z.sql b/pkg/coredata/migrations/20260731T104622Z.sql new file mode 100644 index 0000000000..461ae97c9d --- /dev/null +++ b/pkg/coredata/migrations/20260731T104622Z.sql @@ -0,0 +1,75 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy +-- of this software and associated documentation files (the "Software"), to deal +-- in the Software without restriction, including without limitation the rights +-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +-- copies of the Software, and to permit persons to whom the Software is +-- furnished to do so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in +-- all copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +-- SOFTWARE. + +CREATE TABLE resource_tags ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + organization_id TEXT NOT NULL REFERENCES organizations (id) ON UPDATE CASCADE ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT NOT NULL, + color TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT resource_tags_organization_id_key_key UNIQUE (organization_id, key), + CONSTRAINT resource_tags_key_slug_check CHECK (key ~ '^[a-z0-9]+(-[a-z0-9]+)*$'), + CONSTRAINT resource_tags_color_hex_check CHECK ( + color IS NULL + OR color ~ '^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$' + ) +); + +CREATE TABLE resource_tag_assignments ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + resource_id TEXT NOT NULL, + tag_id TEXT NOT NULL REFERENCES resource_tags (id) ON UPDATE CASCADE ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + CONSTRAINT resource_tag_assignments_resource_id_tag_id_key UNIQUE (resource_id, tag_id) +); + +UPDATE iam_oauth2_clients +SET scopes = '{ + openid, + profile, + email, + offline_access, + v1:access-review, + v1:agent, + v1:asset, + v1:audit, + v1:common-third-party, + v1:compliance-page, + v1:connector, + v1:control, + v1:datum, + v1:document, + v1:iam, + v1:itam, + v1:org, + v1:privacy, + v1:resource-tag, + v1:risk, + v1:slack-connection, + v1:task, + v1:third-party, + v1:webhook +}'::TEXT[], + updated_at = NOW() +WHERE id = 'AAAAAAAAAAAASwAAAAAAAAAAcHJiY2xp'; diff --git a/pkg/coredata/resource_tag.go b/pkg/coredata/resource_tag.go new file mode 100644 index 0000000000..f047f87bf5 --- /dev/null +++ b/pkg/coredata/resource_tag.go @@ -0,0 +1,446 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" + "go.probo.inc/probo/pkg/page" +) + +type ( + ResourceTag struct { + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + Key string `db:"key"` + Value string `db:"value"` + Color *string `db:"color"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + ResourceTags []*ResourceTag +) + +func (t ResourceTag) CursorKey(orderBy ResourceTagOrderField) page.CursorKey { + switch orderBy { + case ResourceTagOrderFieldCreatedAt: + return page.NewCursorKey(t.ID, t.CreatedAt) + case ResourceTagOrderFieldKey: + return page.NewCursorKey(t.ID, t.Key) + } + + panic(fmt.Sprintf("unsupported order by: %s", orderBy)) +} + +func (t *ResourceTag) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM resource_tags WHERE id = ANY(@resource_ids::text[])` + + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, + } + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + + for rows.Next() { + var id, organizationID gid.GID + + if err := rows.Scan(&id, &organizationID); err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil +} + +func (t *ResourceTag) LoadByID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + tagID gid.GID, +) error { + q := ` +SELECT + id, + organization_id, + key, + value, + color, + created_at, + updated_at +FROM + resource_tags +WHERE + %s + AND id = @tag_id +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"tag_id": tagID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query resource tag: %w", err) + } + + tag, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ResourceTag]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect resource tag: %w", err) + } + + *t = tag + + return nil +} + +func (ts *ResourceTags) LoadByOrganizationID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + organizationID gid.GID, + cursor *page.Cursor[ResourceTagOrderField], +) error { + q := ` +SELECT + id, + organization_id, + key, + value, + color, + created_at, + updated_at +FROM + resource_tags +WHERE + %s + AND organization_id = @organization_id + AND %s +` + + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + + args := pgx.NamedArgs{"organization_id": organizationID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query resource tags: %w", err) + } + + tags, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ResourceTag]) + if err != nil { + return fmt.Errorf("cannot collect resource tags: %w", err) + } + + *ts = tags + + return nil +} + +func (ts *ResourceTags) CountByOrganizationID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + organizationID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(*) +FROM + resource_tags +WHERE + %s + AND organization_id = @organization_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "organization_id": organizationID, + } + maps.Copy(args, scope.SQLArguments()) + + row := conn.QueryRow(ctx, q, args) + + var count int + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count resource tags: %w", err) + } + + return count, nil +} + +func (ts *ResourceTags) LoadByResourceID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + resourceID gid.GID, +) error { + q := ` +SELECT + id, + organization_id, + key, + value, + color, + created_at, + updated_at +FROM + resource_tags +WHERE + %s + AND id IN ( + SELECT tag_id + FROM resource_tag_assignments + WHERE resource_id = @resource_id + AND tenant_id = @tenant_id + ) +ORDER BY + key ASC +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"resource_id": resourceID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query resource tags by resource: %w", err) + } + + tags, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ResourceTag]) + if err != nil { + return fmt.Errorf("cannot collect resource tags by resource: %w", err) + } + + *ts = tags + + return nil +} + +func (ts *ResourceTags) LoadByIDs( + ctx context.Context, + conn pg.Querier, + scope Scoper, + tagIDs []gid.GID, +) error { + if len(tagIDs) == 0 { + *ts = nil + + return nil + } + + q := ` +SELECT + id, + organization_id, + key, + value, + color, + created_at, + updated_at +FROM + resource_tags +WHERE + %s + AND id = ANY(@tag_ids) +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"tag_ids": tagIDs} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query resource tags by ids: %w", err) + } + + tags, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ResourceTag]) + if err != nil { + return fmt.Errorf("cannot collect resource tags by ids: %w", err) + } + + *ts = tags + + return nil +} + +func (t *ResourceTag) Insert( + ctx context.Context, + conn pg.Querier, + scope Scoper, +) error { + q := ` +INSERT INTO resource_tags ( + tenant_id, + id, + organization_id, + key, + value, + color, + created_at, + updated_at +) +VALUES ( + @tenant_id, + @id, + @organization_id, + @key, + @value, + @color, + @created_at, + @updated_at +) +` + + args := pgx.StrictNamedArgs{ + "tenant_id": scope.GetTenantID(), + "id": t.ID, + "organization_id": t.OrganizationID, + "key": t.Key, + "value": t.Value, + "color": t.Color, + "created_at": t.CreatedAt, + "updated_at": t.UpdatedAt, + } + + _, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok { + if pgErr.Code == "23505" && pgErr.ConstraintName == "resource_tags_organization_id_key_key" { + return ErrResourceAlreadyExists + } + } + + return fmt.Errorf("cannot insert resource tag: %w", err) + } + + return nil +} + +func (t *ResourceTag) Update( + ctx context.Context, + conn pg.Querier, + scope Scoper, +) error { + q := ` +UPDATE resource_tags +SET + key = @key, + value = @value, + color = @color, + updated_at = @updated_at +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "id": t.ID, + "key": t.Key, + "value": t.Value, + "color": t.Color, + "updated_at": t.UpdatedAt, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok { + if pgErr.Code == "23505" && pgErr.ConstraintName == "resource_tags_organization_id_key_key" { + return ErrResourceAlreadyExists + } + } + + return fmt.Errorf("cannot update resource tag: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + return nil +} + +func (t *ResourceTag) Delete( + ctx context.Context, + conn pg.Querier, + scope Scoper, +) error { + q := ` +DELETE FROM resource_tags +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"id": t.ID} + maps.Copy(args, scope.SQLArguments()) + + _, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot delete resource tag: %w", err) + } + + return nil +} diff --git a/pkg/coredata/resource_tag_assignment.go b/pkg/coredata/resource_tag_assignment.go new file mode 100644 index 0000000000..13567e9380 --- /dev/null +++ b/pkg/coredata/resource_tag_assignment.go @@ -0,0 +1,300 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + ResourceTagAssignment struct { + ID gid.GID `db:"id"` + ResourceID gid.GID `db:"resource_id"` + TagID gid.GID `db:"tag_id"` + CreatedAt time.Time `db:"created_at"` + } + + ResourceTagAssignments []*ResourceTagAssignment +) + +func (a ResourceTagAssignment) CursorKey(orderBy ResourceTagAssignmentOrderField) page.CursorKey { + switch orderBy { + case ResourceTagAssignmentOrderFieldCreatedAt: + return page.NewCursorKey(a.ID, a.CreatedAt) + } + + panic(fmt.Sprintf("unsupported order by: %s", orderBy)) +} + +func (a *ResourceTagAssignment) Insert( + ctx context.Context, + conn pg.Querier, + scope Scoper, +) error { + q := ` +INSERT INTO resource_tag_assignments ( + tenant_id, + id, + resource_id, + tag_id, + created_at +) +VALUES ( + @tenant_id, + @id, + @resource_id, + @tag_id, + @created_at +) +` + + args := pgx.StrictNamedArgs{ + "tenant_id": scope.GetTenantID(), + "id": a.ID, + "resource_id": a.ResourceID, + "tag_id": a.TagID, + "created_at": a.CreatedAt, + } + + _, err := conn.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok { + if pgErr.Code == "23505" && pgErr.ConstraintName == "resource_tag_assignments_resource_id_tag_id_key" { + return ErrResourceAlreadyExists + } + } + + return fmt.Errorf("cannot insert resource tag assignment: %w", err) + } + + return nil +} + +func (a *ResourceTagAssignment) Delete( + ctx context.Context, + conn pg.Querier, + scope Scoper, +) error { + q := ` +DELETE FROM resource_tag_assignments +WHERE + %s + AND resource_id = @resource_id + AND tag_id = @tag_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "resource_id": a.ResourceID, + "tag_id": a.TagID, + } + maps.Copy(args, scope.SQLArguments()) + + _, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot delete resource tag assignment: %w", err) + } + + return nil +} + +func (as *ResourceTagAssignments) LoadByTagID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + tagID gid.GID, + cursor *page.Cursor[ResourceTagAssignmentOrderField], +) error { + q := ` +SELECT + id, + resource_id, + tag_id, + created_at +FROM + resource_tag_assignments +WHERE + %s + AND tag_id = @tag_id + AND %s +` + + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + + args := pgx.NamedArgs{"tag_id": tagID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query resource tag assignments: %w", err) + } + + assignments, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ResourceTagAssignment]) + if err != nil { + return fmt.Errorf("cannot collect resource tag assignments: %w", err) + } + + *as = assignments + + return nil +} + +func (as *ResourceTagAssignments) CountByTagID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + tagID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(*) +FROM + resource_tag_assignments +WHERE + %s + AND tag_id = @tag_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"tag_id": tagID} + maps.Copy(args, scope.SQLArguments()) + + row := conn.QueryRow(ctx, q, args) + + var count int + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count resource tag assignments: %w", err) + } + + return count, nil +} + +func (as *ResourceTagAssignments) LoadByResourceIDs( + ctx context.Context, + conn pg.Querier, + scope Scoper, + resourceIDs []gid.GID, +) error { + if len(resourceIDs) == 0 { + *as = nil + + return nil + } + + q := ` +SELECT + id, + resource_id, + tag_id, + created_at +FROM + resource_tag_assignments +WHERE + %s + AND resource_id = ANY(@resource_ids) +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"resource_ids": resourceIDs} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query resource tag assignments: %w", err) + } + + assignments, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ResourceTagAssignment]) + if err != nil { + return fmt.Errorf("cannot collect resource tag assignments: %w", err) + } + + *as = assignments + + return nil +} + +// FilterResourceIDs returns the subset of candidateIDs that are assigned every +// tag in tagIDs (AND semantics). Empty tagIDs returns candidateIDs unchanged. +func (as *ResourceTagAssignments) FilterResourceIDs( + ctx context.Context, + conn pg.Querier, + scope Scoper, + candidateIDs []gid.GID, + tagIDs []gid.GID, +) ([]gid.GID, error) { + if len(candidateIDs) == 0 { + return nil, nil + } + + if len(tagIDs) == 0 { + return candidateIDs, nil + } + + q := ` +SELECT + resource_id +FROM + resource_tag_assignments +WHERE + %s + AND resource_id = ANY(@resource_ids) + AND tag_id = ANY(@tag_ids) +GROUP BY + resource_id +HAVING + COUNT(DISTINCT tag_id) = @tag_count +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "resource_ids": candidateIDs, + "tag_ids": tagIDs, + "tag_count": len(tagIDs), + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot filter resource ids by tags: %w", err) + } + + ids, err := pgx.CollectRows(rows, pgx.RowTo[gid.GID]) + if err != nil { + return nil, fmt.Errorf("cannot collect filtered resource ids: %w", err) + } + + return ids, nil +} diff --git a/pkg/coredata/resource_tag_assignment_order_field.go b/pkg/coredata/resource_tag_assignment_order_field.go new file mode 100644 index 0000000000..d107f13828 --- /dev/null +++ b/pkg/coredata/resource_tag_assignment_order_field.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package coredata + +import ( + "encoding" + "fmt" + + "go.probo.inc/probo/pkg/page" +) + +type ( + ResourceTagAssignmentOrderField string +) + +const ( + ResourceTagAssignmentOrderFieldCreatedAt ResourceTagAssignmentOrderField = "CREATED_AT" +) + +var ( + _ page.OrderField = ResourceTagAssignmentOrderField("") + _ fmt.Stringer = ResourceTagAssignmentOrderField("") + _ encoding.TextMarshaler = ResourceTagAssignmentOrderField("") + _ encoding.TextUnmarshaler = (*ResourceTagAssignmentOrderField)(nil) +) + +func ResourceTagAssignmentOrderFields() []ResourceTagAssignmentOrderField { + return []ResourceTagAssignmentOrderField{ + ResourceTagAssignmentOrderFieldCreatedAt, + } +} + +func (v ResourceTagAssignmentOrderField) IsValid() bool { + switch v { + case ResourceTagAssignmentOrderFieldCreatedAt: + return true + } + + return false +} + +func (v ResourceTagAssignmentOrderField) String() string { + return string(v) +} + +func (v ResourceTagAssignmentOrderField) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *ResourceTagAssignmentOrderField) UnmarshalText(text []byte) error { + val := ResourceTagAssignmentOrderField(text) + if !val.IsValid() { + return fmt.Errorf("invalid ResourceTagAssignmentOrderField value: %q", string(text)) + } + + *v = val + + return nil +} + +func (p ResourceTagAssignmentOrderField) Column() string { + switch p { + case ResourceTagAssignmentOrderFieldCreatedAt: + return "created_at" + } + + return string(p) +} diff --git a/pkg/coredata/resource_tag_order_field.go b/pkg/coredata/resource_tag_order_field.go new file mode 100644 index 0000000000..9520b0ee0f --- /dev/null +++ b/pkg/coredata/resource_tag_order_field.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package coredata + +import ( + "encoding" + "fmt" + + "go.probo.inc/probo/pkg/page" +) + +type ( + ResourceTagOrderField string +) + +const ( + ResourceTagOrderFieldCreatedAt ResourceTagOrderField = "CREATED_AT" + ResourceTagOrderFieldKey ResourceTagOrderField = "KEY" +) + +var ( + _ page.OrderField = ResourceTagOrderField("") + _ fmt.Stringer = ResourceTagOrderField("") + _ encoding.TextMarshaler = ResourceTagOrderField("") + _ encoding.TextUnmarshaler = (*ResourceTagOrderField)(nil) +) + +func ResourceTagOrderFields() []ResourceTagOrderField { + return []ResourceTagOrderField{ + ResourceTagOrderFieldCreatedAt, + ResourceTagOrderFieldKey, + } +} + +func (v ResourceTagOrderField) IsValid() bool { + switch v { + case + ResourceTagOrderFieldCreatedAt, + ResourceTagOrderFieldKey: + return true + } + + return false +} + +func (v ResourceTagOrderField) String() string { + return string(v) +} + +func (v ResourceTagOrderField) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *ResourceTagOrderField) UnmarshalText(text []byte) error { + val := ResourceTagOrderField(text) + if !val.IsValid() { + return fmt.Errorf("invalid ResourceTagOrderField value: %q", string(text)) + } + + *v = val + + return nil +} + +func (p ResourceTagOrderField) Column() string { + switch p { + case ResourceTagOrderFieldCreatedAt: + return "created_at" + case ResourceTagOrderFieldKey: + return "key" + } + + return string(p) +} diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index f68e0e93de..08dfa224a7 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -76,6 +76,7 @@ import ( "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/resourcealias" + "go.probo.inc/probo/pkg/resourcetag" "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/securecookie" "go.probo.inc/probo/pkg/server" @@ -553,6 +554,7 @@ func (impl *Implm) Run( Register(agentrun.OAuth2ScopeMappings). Register(accessreview.OAuth2ScopeMappings). Register(resourcealias.OAuth2ScopeMappings). + Register(resourcetag.OAuth2ScopeMappings). Register(itam.OAuth2ScopeMappings) var accountKey crypto.Signer @@ -677,6 +679,7 @@ func (impl *Implm) Run( ) resourceAliasService := resourcealias.NewService(pgClient) + resourceTagService := resourcetag.NewService(pgClient) managementService := management.NewService( pgClient, @@ -773,6 +776,7 @@ func (impl *Implm) Run( iamService.Authorizer.RegisterPolicySet(agentrun.PolicySet()) iamService.Authorizer.RegisterPolicySet(accessreview.PolicySet()) iamService.Authorizer.RegisterPolicySet(resourcealias.PolicySet()) + iamService.Authorizer.RegisterPolicySet(resourcetag.PolicySet()) iamService.Authorizer.RegisterPolicySet(management.PolicySet()) thirdPartyService := thirdparty.NewService(pgClient, fileManagerService, thirdPartyVetter) @@ -793,6 +797,7 @@ func (impl *Implm) Run( ExtraHeaderFields: impl.cfg.Api.ExtraHeaderFields, Probo: proboService, ResourceAlias: resourceAliasService, + ResourceTag: resourceTagService, File: fileManagerService, IAM: iamService, Visitor: visitorService, diff --git a/pkg/resourcetag/actions.go b/pkg/resourcetag/actions.go new file mode 100644 index 0000000000..e3a977ca50 --- /dev/null +++ b/pkg/resourcetag/actions.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package resourcetag + +// Resource tag service actions. +// Format: resourcetag:tag: / resourcetag:assignment: +const ( + ActionTagList = "resourcetag:tag:list" + ActionTagGet = "resourcetag:tag:get" + ActionTagCreate = "resourcetag:tag:create" + ActionTagUpdate = "resourcetag:tag:update" + ActionTagDelete = "resourcetag:tag:delete" + + ActionAssignmentGet = "resourcetag:assignment:get" + ActionAssignmentAttach = "resourcetag:assignment:attach" + ActionAssignmentDetach = "resourcetag:assignment:detach" +) diff --git a/pkg/resourcetag/oauth2_scopes.go b/pkg/resourcetag/oauth2_scopes.go new file mode 100644 index 0000000000..6bad02d0c8 --- /dev/null +++ b/pkg/resourcetag/oauth2_scopes.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package resourcetag + +import "go.probo.inc/probo/pkg/coredata" + +const ( + ScopeV1ResourceTagRead coredata.OAuth2Scope = "v1:resource-tag:read" + ScopeV1ResourceTag coredata.OAuth2Scope = "v1:resource-tag" +) + +// OAuth2ScopeMappings maps OAuth2 scopes to resource-tag actions. +var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{ + ScopeV1ResourceTagRead: { + ActionTagList, + ActionTagGet, + ActionAssignmentGet, + }, + ScopeV1ResourceTag: { + ActionTagList, + ActionTagGet, + ActionTagCreate, + ActionTagUpdate, + ActionTagDelete, + ActionAssignmentGet, + ActionAssignmentAttach, + ActionAssignmentDetach, + }, +} diff --git a/pkg/resourcetag/policies.go b/pkg/resourcetag/policies.go new file mode 100644 index 0000000000..37161d958b --- /dev/null +++ b/pkg/resourcetag/policies.go @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package resourcetag + +import ( + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/iam/policy" +) + +var organizationCondition = policy.Equals("principal.organization_id", "resource.organization_id") + +// FullAccessPolicy grants complete resource-tag access to organization owners +// and admins. +var FullAccessPolicy = policy.NewPolicy( + "resourcetag:full-access", + "Resource Tag Full Access", + policy.Allow( + ActionTagList, + ActionTagGet, + ActionTagCreate, + ActionTagUpdate, + ActionTagDelete, + ActionAssignmentGet, + ActionAssignmentAttach, + ActionAssignmentDetach, + ).WithSID("resource-tag-full-access").When(organizationCondition), +).WithDescription("Full resource-tag access including catalog and assignments") + +// ReadAccessPolicy grants read-only resource-tag access to viewers and auditors. +var ReadAccessPolicy = policy.NewPolicy( + "resourcetag:read-access", + "Resource Tag Read Access", + policy.Allow( + ActionTagList, + ActionTagGet, + ActionAssignmentGet, + ).WithSID("resource-tag-read-access").When(organizationCondition), +).WithDescription("Read-only resource-tag access") + +// PolicySet returns the PolicySet for the resource-tag service. +func PolicySet() *iam.PolicySet { + return iam.NewPolicySet(). + AddRolePolicy("OWNER", FullAccessPolicy). + AddRolePolicy("ADMIN", FullAccessPolicy). + AddRolePolicy("VIEWER", ReadAccessPolicy). + AddRolePolicy("AUDITOR", ReadAccessPolicy) +} diff --git a/pkg/resourcetag/service.go b/pkg/resourcetag/service.go new file mode 100644 index 0000000000..9d1ecf4cb2 --- /dev/null +++ b/pkg/resourcetag/service.go @@ -0,0 +1,549 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package resourcetag + +import ( + "context" + "fmt" + "time" + + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" + "go.probo.inc/probo/pkg/validator" +) + +const ( + keyMaxLength = 100 + valueMaxLength = 200 +) + +type ( + Service struct { + pg *pg.Client + } + + CreateTagRequest struct { + OrganizationID gid.GID + Key string + Value string + Color *string + } + + UpdateTagRequest struct { + ID gid.GID + Key *string + Value *string + Color *string + } + + AttachRequest struct { + ResourceID gid.GID + TagID gid.GID + } + + DetachRequest struct { + ResourceID gid.GID + TagID gid.GID + } +) + +func NewService(pgClient *pg.Client) *Service { + return &Service{ + pg: pgClient, + } +} + +func (req *CreateTagRequest) Validate() error { + v := validator.New() + + v.Check(req.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType)) + v.Check(req.Key, "key", validator.Required(), validator.Slug(keyMaxLength)) + v.Check(req.Value, "value", validator.Required(), validator.SafeText(valueMaxLength)) + v.Check(req.Color, "color", validator.HexColor()) + + return v.Error() +} + +func (req *UpdateTagRequest) Validate() error { + v := validator.New() + + v.Check(req.ID, "id", validator.Required(), validator.GID(coredata.ResourceTagEntityType)) + v.Check(req.Key, "key", validator.NotEmpty(), validator.Slug(keyMaxLength)) + v.Check(req.Value, "value", validator.NotEmpty(), validator.SafeText(valueMaxLength)) + v.Check(req.Color, "color", validator.HexColor()) + + return v.Error() +} + +func (req *AttachRequest) Validate() error { + v := validator.New() + + v.Check(req.ResourceID, "resource_id", validator.Required(), validator.GID()) + v.Check(req.TagID, "tag_id", validator.Required(), validator.GID(coredata.ResourceTagEntityType)) + + return v.Error() +} + +func (req *DetachRequest) Validate() error { + v := validator.New() + + v.Check(req.ResourceID, "resource_id", validator.Required(), validator.GID()) + v.Check(req.TagID, "tag_id", validator.Required(), validator.GID(coredata.ResourceTagEntityType)) + + return v.Error() +} + +func (s *Service) Get( + ctx context.Context, + scope coredata.Scoper, + tagID gid.GID, +) (*coredata.ResourceTag, error) { + tag := &coredata.ResourceTag{} + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := tag.LoadByID(ctx, conn, scope, tagID); err != nil { + return fmt.Errorf("cannot load resource tag: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return tag, nil +} + +func (s *Service) ListForOrganizationID( + ctx context.Context, + scope coredata.Scoper, + organizationID gid.GID, + cursor *page.Cursor[coredata.ResourceTagOrderField], +) (*page.Page[*coredata.ResourceTag, coredata.ResourceTagOrderField], error) { + var tags coredata.ResourceTags + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + organization := &coredata.Organization{} + if err := organization.LoadByID(ctx, conn, scope, organizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + if err := tags.LoadByOrganizationID(ctx, conn, scope, organization.ID, cursor); err != nil { + return fmt.Errorf("cannot load resource tags: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return page.NewPage(tags, cursor), nil +} + +func (s *Service) CountForOrganizationID( + ctx context.Context, + scope coredata.Scoper, + organizationID gid.GID, +) (int, error) { + var count int + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + tags := &coredata.ResourceTags{} + + var err error + count, err = tags.CountByOrganizationID(ctx, conn, scope, organizationID) + if err != nil { + return fmt.Errorf("cannot count resource tags: %w", err) + } + + return nil + }, + ) + if err != nil { + return 0, err + } + + return count, nil +} + +func (s *Service) Create( + ctx context.Context, + scope coredata.Scoper, + req CreateTagRequest, +) (*coredata.ResourceTag, error) { + if err := req.Validate(); err != nil { + return nil, err + } + + now := time.Now() + + color := req.Color + if color != nil && *color == "" { + color = nil + } + + tag := &coredata.ResourceTag{ + ID: gid.New(scope.GetTenantID(), coredata.ResourceTagEntityType), + OrganizationID: req.OrganizationID, + Key: req.Key, + Value: req.Value, + Color: color, + CreatedAt: now, + UpdatedAt: now, + } + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + organization := &coredata.Organization{} + if err := organization.LoadByID(ctx, conn, scope, req.OrganizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + if err := tag.Insert(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot create resource tag: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return tag, nil +} + +func (s *Service) Update( + ctx context.Context, + scope coredata.Scoper, + req UpdateTagRequest, +) (*coredata.ResourceTag, error) { + if err := req.Validate(); err != nil { + return nil, err + } + + tag := &coredata.ResourceTag{} + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := tag.LoadByID(ctx, conn, scope, req.ID); err != nil { + return fmt.Errorf("cannot load resource tag: %w", err) + } + + if req.Key != nil { + tag.Key = *req.Key + } + + if req.Value != nil { + tag.Value = *req.Value + } + + if req.Color != nil { + if *req.Color == "" { + tag.Color = nil + } else { + tag.Color = req.Color + } + } + + tag.UpdatedAt = time.Now() + + if err := tag.Update(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot update resource tag: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return tag, nil +} + +func (s *Service) Delete( + ctx context.Context, + scope coredata.Scoper, + tagID gid.GID, +) error { + return s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + tag := &coredata.ResourceTag{} + if err := tag.LoadByID(ctx, conn, scope, tagID); err != nil { + return fmt.Errorf("cannot load resource tag: %w", err) + } + + if err := tag.Delete(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot delete resource tag: %w", err) + } + + return nil + }, + ) +} + +func (s *Service) Attach( + ctx context.Context, + scope coredata.Scoper, + req AttachRequest, +) error { + if err := req.Validate(); err != nil { + return err + } + + return s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + tag := &coredata.ResourceTag{} + if err := tag.LoadByID(ctx, conn, scope, req.TagID); err != nil { + return fmt.Errorf("cannot load resource tag: %w", err) + } + + assignment := &coredata.ResourceTagAssignment{ + ID: gid.New(scope.GetTenantID(), coredata.ResourceTagAssignmentEntityType), + ResourceID: req.ResourceID, + TagID: req.TagID, + CreatedAt: time.Now(), + } + + if err := assignment.Insert(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot attach resource tag: %w", err) + } + + return nil + }, + ) +} + +func (s *Service) Detach( + ctx context.Context, + scope coredata.Scoper, + req DetachRequest, +) error { + if err := req.Validate(); err != nil { + return err + } + + return s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + assignment := &coredata.ResourceTagAssignment{ + ResourceID: req.ResourceID, + TagID: req.TagID, + } + + if err := assignment.Delete(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot detach resource tag: %w", err) + } + + return nil + }, + ) +} + +func (s *Service) ListForResourceID( + ctx context.Context, + scope coredata.Scoper, + resourceID gid.GID, +) ([]*coredata.ResourceTag, error) { + var tags coredata.ResourceTags + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := tags.LoadByResourceID(ctx, conn, scope, resourceID); err != nil { + return fmt.Errorf("cannot load resource tags for resource: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return tags, nil +} + +// LoadByResourceIDs returns tags keyed by resource ID for dataloader / field +// resolvers. Ready for the follow-up PR that wires tags onto entity types. +func (s *Service) LoadByResourceIDs( + ctx context.Context, + scope coredata.Scoper, + resourceIDs []gid.GID, +) (map[gid.GID][]*coredata.ResourceTag, error) { + result := make(map[gid.GID][]*coredata.ResourceTag, len(resourceIDs)) + if len(resourceIDs) == 0 { + return result, nil + } + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + var assignments coredata.ResourceTagAssignments + if err := assignments.LoadByResourceIDs(ctx, conn, scope, resourceIDs); err != nil { + return fmt.Errorf("cannot load resource tag assignments: %w", err) + } + + tagIDs := make([]gid.GID, 0, len(assignments)) + seen := make(map[gid.GID]struct{}, len(assignments)) + for _, assignment := range assignments { + if _, ok := seen[assignment.TagID]; ok { + continue + } + + seen[assignment.TagID] = struct{}{} + tagIDs = append(tagIDs, assignment.TagID) + } + + var tags coredata.ResourceTags + if err := tags.LoadByIDs(ctx, conn, scope, tagIDs); err != nil { + return fmt.Errorf("cannot load resource tags: %w", err) + } + + tagsByID := make(map[gid.GID]*coredata.ResourceTag, len(tags)) + for _, tag := range tags { + tagsByID[tag.ID] = tag + } + + for _, assignment := range assignments { + tag, ok := tagsByID[assignment.TagID] + if !ok { + continue + } + + result[assignment.ResourceID] = append(result[assignment.ResourceID], tag) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return result, nil +} + +// FilterResourceIDs returns candidates that have all of the given tags (AND). +func (s *Service) FilterResourceIDs( + ctx context.Context, + scope coredata.Scoper, + candidateIDs []gid.GID, + tagIDs []gid.GID, +) ([]gid.GID, error) { + var filtered []gid.GID + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + assignments := &coredata.ResourceTagAssignments{} + + var err error + filtered, err = assignments.FilterResourceIDs(ctx, conn, scope, candidateIDs, tagIDs) + if err != nil { + return fmt.Errorf("cannot filter resource ids by tags: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return filtered, nil +} + +// ListAssignmentsForTagID returns a page of assignments for the given tag. +func (s *Service) ListAssignmentsForTagID( + ctx context.Context, + scope coredata.Scoper, + tagID gid.GID, + cursor *page.Cursor[coredata.ResourceTagAssignmentOrderField], +) (*page.Page[*coredata.ResourceTagAssignment, coredata.ResourceTagAssignmentOrderField], error) { + var assignments coredata.ResourceTagAssignments + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + tag := &coredata.ResourceTag{} + if err := tag.LoadByID(ctx, conn, scope, tagID); err != nil { + return fmt.Errorf("cannot load resource tag: %w", err) + } + + if err := assignments.LoadByTagID(ctx, conn, scope, tagID, cursor); err != nil { + return fmt.Errorf("cannot list resource tag assignments: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return page.NewPage(assignments, cursor), nil +} + +// CountAssignmentsForTagID returns how many resources are assigned the tag. +func (s *Service) CountAssignmentsForTagID( + ctx context.Context, + scope coredata.Scoper, + tagID gid.GID, +) (int, error) { + var count int + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + assignments := &coredata.ResourceTagAssignments{} + + var err error + count, err = assignments.CountByTagID(ctx, conn, scope, tagID) + if err != nil { + return fmt.Errorf("cannot count resource tag assignments: %w", err) + } + + return nil + }, + ) + if err != nil { + return 0, err + } + + return count, nil +} diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go index 12c7406cec..8502688b71 100644 --- a/pkg/server/api/api.go +++ b/pkg/server/api/api.go @@ -47,6 +47,7 @@ import ( "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/resourcealias" + "go.probo.inc/probo/pkg/resourcetag" "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/saferedirect" "go.probo.inc/probo/pkg/securecookie" @@ -68,6 +69,7 @@ type ( AllowedOrigins []string Probo *probo.Service ResourceAlias *resourcealias.Service + ResourceTag *resourcetag.Service File *filemanager.Service IAM *iam.Service Visitor *visitor.Service @@ -205,6 +207,7 @@ func NewServer(cfg Config) (*Server, error) { cfg.Logger.Named("console.v1"), cfg.Probo, cfg.ResourceAlias, + cfg.ResourceTag, cfg.IAM, cfg.ESign, cfg.Management, @@ -245,6 +248,7 @@ func NewServer(cfg Config) (*Server, error) { cfg.Management, cfg.CertManager, cfg.ResourceAlias, + cfg.ResourceTag, cfg.ThirdParty, cfg.IAM, cfg.AccessReview, diff --git a/pkg/server/api/console/v1/base_resolvers.go b/pkg/server/api/console/v1/base_resolvers.go index 45317d19aa..8e3afff6f2 100644 --- a/pkg/server/api/console/v1/base_resolvers.go +++ b/pkg/server/api/console/v1/base_resolvers.go @@ -19,6 +19,7 @@ import ( "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/resourcetag" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/console/v1/schema" "go.probo.inc/probo/pkg/server/api/console/v1/types" @@ -501,6 +502,16 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error UpdatedAt: version.UpdatedAt, }, nil } + case coredata.ResourceTagEntityType: + action = resourcetag.ActionTagGet + loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) { + tag, err := r.resourceTag.Get(ctx, scope, id) + if err != nil { + return nil, err + } + + return types.NewResourceTag(tag), nil + } default: } diff --git a/pkg/server/api/console/v1/graphql/resource_tag.graphql b/pkg/server/api/console/v1/graphql/resource_tag.graphql new file mode 100644 index 0000000000..48f22afae9 --- /dev/null +++ b/pkg/server/api/console/v1/graphql/resource_tag.graphql @@ -0,0 +1,193 @@ +# Copyright (c) 2026 Probo Inc . +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +enum ResourceTagOrderField + @goModel(model: "go.probo.inc/probo/pkg/coredata.ResourceTagOrderField") { + CREATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.ResourceTagOrderFieldCreatedAt" + ) + KEY + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.ResourceTagOrderFieldKey" + ) +} + +input ResourceTagOrder + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.ResourceTagOrderBy" + ) { + direction: OrderDirection! + field: ResourceTagOrderField! +} + +type ResourceTag implements Node { + id: ID! + organization: Organization! @goField(forceResolver: true) + key: String! + value: String! + color: String + createdAt: Datetime! + updatedAt: Datetime! + + assignments( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: ResourceTagAssignmentOrder + ): ResourceTagAssignmentConnection! @goField(forceResolver: true) + + permission(action: String!): Boolean! @goField(forceResolver: true) +} + +enum ResourceTagAssignmentOrderField + @goModel( + model: "go.probo.inc/probo/pkg/coredata.ResourceTagAssignmentOrderField" + ) { + CREATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.ResourceTagAssignmentOrderFieldCreatedAt" + ) +} + +input ResourceTagAssignmentOrder + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.ResourceTagAssignmentOrderBy" + ) { + direction: OrderDirection! + field: ResourceTagAssignmentOrderField! +} + +type ResourceTagAssignment { + id: ID! + resourceId: ID! + createdAt: Datetime! +} + +type ResourceTagAssignmentConnection + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.ResourceTagAssignmentConnection" + ) { + edges: [ResourceTagAssignmentEdge!]! + pageInfo: PageInfo! + totalCount: Int! @goField(forceResolver: true) +} + +type ResourceTagAssignmentEdge { + cursor: CursorKey! + node: ResourceTagAssignment! +} + +type ResourceTagConnection + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.ResourceTagConnection" + ) { + edges: [ResourceTagEdge!]! + pageInfo: PageInfo! + totalCount: Int! @goField(forceResolver: true) +} + +type ResourceTagEdge { + cursor: CursorKey! + node: ResourceTag! +} + +extend type Organization { + resourceTags( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: ResourceTagOrder + ): ResourceTagConnection! @goField(forceResolver: true) +} + +extend type Query { + resourceTagsForResource(resourceId: ID!): [ResourceTag!]! +} + +extend type Mutation { + createResourceTag( + input: CreateResourceTagInput! + ): CreateResourceTagPayload! + updateResourceTag( + input: UpdateResourceTagInput! + ): UpdateResourceTagPayload! + deleteResourceTag( + input: DeleteResourceTagInput! + ): DeleteResourceTagPayload! + attachResourceTag( + input: AttachResourceTagInput! + ): AttachResourceTagPayload! + detachResourceTag( + input: DetachResourceTagInput! + ): DetachResourceTagPayload! +} + +input CreateResourceTagInput { + organizationId: ID! + key: String! + value: String! + color: String +} + +input UpdateResourceTagInput { + id: ID! + key: String + value: String + color: String +} + +input DeleteResourceTagInput { + resourceTagId: ID! +} + +input AttachResourceTagInput { + resourceId: ID! + tagId: ID! +} + +input DetachResourceTagInput { + resourceId: ID! + tagId: ID! +} + +type CreateResourceTagPayload { + resourceTagEdge: ResourceTagEdge! +} + +type UpdateResourceTagPayload { + resourceTag: ResourceTag! +} + +type DeleteResourceTagPayload { + deletedResourceTagId: ID! +} + +type AttachResourceTagPayload { + resourceId: ID! + tagId: ID! +} + +type DetachResourceTagPayload { + resourceId: ID! + tagId: ID! +} diff --git a/pkg/server/api/console/v1/graphql_handler.go b/pkg/server/api/console/v1/graphql_handler.go index b98f6be9df..52147b9fc0 100644 --- a/pkg/server/api/console/v1/graphql_handler.go +++ b/pkg/server/api/console/v1/graphql_handler.go @@ -39,6 +39,7 @@ import ( "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/resourcealias" + "go.probo.inc/probo/pkg/resourcetag" "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/server/api/authz" "go.probo.inc/probo/pkg/server/api/console/v1/dataloader" @@ -51,6 +52,7 @@ func NewGraphQLHandler( iamSvc *iam.Service, proboSvc *probo.Service, resourceAliasSvc *resourcealias.Service, + resourceTagSvc *resourcetag.Service, esignSvc *esign.Service, managementSvc *management.Service, certManagerSvc *certmanager.Service, @@ -76,6 +78,7 @@ func NewGraphQLHandler( batchAuthorize: authz.NewBatchAuthorizeFunc(iamSvc, logger), probo: proboSvc, resourceAlias: resourceAliasSvc, + resourceTag: resourceTagSvc, iam: iamSvc, esign: esignSvc, management: managementSvc, diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index 4217d0d67a..65afa0363e 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -50,6 +50,7 @@ import ( "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/resourcealias" + "go.probo.inc/probo/pkg/resourcetag" "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/saferedirect" "go.probo.inc/probo/pkg/securecookie" @@ -67,6 +68,7 @@ type ( batchAuthorize authz.BatchAuthorizeFunc probo *probo.Service resourceAlias *resourcealias.Service + resourceTag *resourcetag.Service iam *iam.Service esign *esign.Service management *management.Service @@ -92,6 +94,7 @@ func NewMux( logger *log.Logger, proboSvc *probo.Service, resourceAliasSvc *resourcealias.Service, + resourceTagSvc *resourcetag.Service, iamSvc *iam.Service, esignSvc *esign.Service, managementSvc *management.Service, @@ -120,6 +123,7 @@ func NewMux( iamSvc, proboSvc, resourceAliasSvc, + resourceTagSvc, esignSvc, managementSvc, certManagerSvc, diff --git a/pkg/server/api/console/v1/resource_tag_resolvers.go b/pkg/server/api/console/v1/resource_tag_resolvers.go new file mode 100644 index 0000000000..d85eaa5bd7 --- /dev/null +++ b/pkg/server/api/console/v1/resource_tag_resolvers.go @@ -0,0 +1,368 @@ +package console_v1 + +// This file will be automatically regenerated based on the schema, any resolver +// implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by github.com/99designs/gqlgen version v0.17.94 + +import ( + "context" + "errors" + "fmt" + + "github.com/vikstrous/dataloadgen" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" + "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/resourcetag" + "go.probo.inc/probo/pkg/server/api/console/v1/dataloader" + "go.probo.inc/probo/pkg/server/api/console/v1/schema" + "go.probo.inc/probo/pkg/server/api/console/v1/types" + "go.probo.inc/probo/pkg/server/gqlutils" + "go.probo.inc/probo/pkg/validator" +) + +// CreateResourceTag is the resolver for the createResourceTag field. +func (r *mutationResolver) CreateResourceTag(ctx context.Context, input types.CreateResourceTagInput) (*types.CreateResourceTagPayload, error) { + scope, err := r.authorize(ctx, input.OrganizationID, resourcetag.ActionTagCreate) + if err != nil { + return nil, err + } + + tag, err := r.resourceTag.Create( + ctx, + scope, + resourcetag.CreateTagRequest{ + OrganizationID: input.OrganizationID, + Key: input.Key, + Value: input.Value, + Color: input.Color, + }, + ) + if err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return nil, gqlutils.Conflict(ctx, err) + } + + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + + r.logger.ErrorCtx(ctx, "cannot create resource tag", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.CreateResourceTagPayload{ + ResourceTagEdge: types.NewResourceTagEdge(tag, coredata.ResourceTagOrderFieldCreatedAt), + }, nil +} + +// UpdateResourceTag is the resolver for the updateResourceTag field. +func (r *mutationResolver) UpdateResourceTag(ctx context.Context, input types.UpdateResourceTagInput) (*types.UpdateResourceTagPayload, error) { + scope, err := r.authorize(ctx, input.ID, resourcetag.ActionTagUpdate) + if err != nil { + return nil, err + } + + tag, err := r.resourceTag.Update( + ctx, + scope, + resourcetag.UpdateTagRequest{ + ID: input.ID, + Key: input.Key, + Value: input.Value, + Color: input.Color, + }, + ) + if err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return nil, gqlutils.Conflict(ctx, err) + } + + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + + r.logger.ErrorCtx(ctx, "cannot update resource tag", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.UpdateResourceTagPayload{ + ResourceTag: types.NewResourceTag(tag), + }, nil +} + +// DeleteResourceTag is the resolver for the deleteResourceTag field. +func (r *mutationResolver) DeleteResourceTag(ctx context.Context, input types.DeleteResourceTagInput) (*types.DeleteResourceTagPayload, error) { + scope, err := r.authorize(ctx, input.ResourceTagID, resourcetag.ActionTagDelete) + if err != nil { + return nil, err + } + + if err := r.resourceTag.Delete(ctx, scope, input.ResourceTagID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + + r.logger.ErrorCtx(ctx, "cannot delete resource tag", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.DeleteResourceTagPayload{ + DeletedResourceTagID: input.ResourceTagID, + }, nil +} + +// AttachResourceTag is the resolver for the attachResourceTag field. +func (r *mutationResolver) AttachResourceTag(ctx context.Context, input types.AttachResourceTagInput) (*types.AttachResourceTagPayload, error) { + scope, err := r.authorize(ctx, input.ResourceID, resourcetag.ActionAssignmentAttach) + if err != nil { + return nil, err + } + + err = r.resourceTag.Attach( + ctx, + scope, + resourcetag.AttachRequest{ + ResourceID: input.ResourceID, + TagID: input.TagID, + }, + ) + if err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return nil, gqlutils.Conflict(ctx, err) + } + + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + + r.logger.ErrorCtx(ctx, "cannot attach resource tag", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.AttachResourceTagPayload{ + ResourceID: input.ResourceID, + TagID: input.TagID, + }, nil +} + +// DetachResourceTag is the resolver for the detachResourceTag field. +func (r *mutationResolver) DetachResourceTag(ctx context.Context, input types.DetachResourceTagInput) (*types.DetachResourceTagPayload, error) { + scope, err := r.authorize(ctx, input.ResourceID, resourcetag.ActionAssignmentDetach) + if err != nil { + return nil, err + } + + err = r.resourceTag.Detach( + ctx, + scope, + resourcetag.DetachRequest{ + ResourceID: input.ResourceID, + TagID: input.TagID, + }, + ) + if err != nil { + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + + r.logger.ErrorCtx(ctx, "cannot detach resource tag", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.DetachResourceTagPayload{ + ResourceID: input.ResourceID, + TagID: input.TagID, + }, nil +} + +// ResourceTags is the resolver for the resourceTags field. +func (r *organizationResolver) ResourceTags(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ResourceTagOrderBy) (*types.ResourceTagConnection, error) { + scope, err := r.authorize(ctx, obj.ID, resourcetag.ActionTagList) + if err != nil { + return nil, err + } + + pageOrderBy := page.OrderBy[coredata.ResourceTagOrderField]{ + Field: coredata.ResourceTagOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.ResourceTagOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + pageResult, err := r.resourceTag.ListForOrganizationID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list organization resource tags", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewResourceTagConnection(pageResult, r, obj.ID), nil +} + +// ResourceTagsForResource is the resolver for the resourceTagsForResource field. +func (r *queryResolver) ResourceTagsForResource(ctx context.Context, resourceID gid.GID) ([]*types.ResourceTag, error) { + scope, err := r.authorize(ctx, resourceID, resourcetag.ActionAssignmentGet) + if err != nil { + return nil, err + } + + tags, err := r.resourceTag.ListForResourceID(ctx, scope, resourceID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list resource tags for resource", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + result := make([]*types.ResourceTag, len(tags)) + for i, tag := range tags { + result[i] = types.NewResourceTag(tag) + } + + return result, nil +} + +// Organization is the resolver for the organization field. +func (r *resourceTagResolver) Organization(ctx context.Context, obj *types.ResourceTag) (*types.Organization, error) { + if _, err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil { + return nil, err + } + + loaders := dataloader.FromContext(ctx) + + organization, err := loaders.Organization.Load(ctx, obj.Organization.ID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + + r.logger.ErrorCtx(ctx, "cannot load organization", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return types.NewOrganization(organization), nil +} + +// Assignments is the resolver for the assignments field. +func (r *resourceTagResolver) Assignments(ctx context.Context, obj *types.ResourceTag, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ResourceTagAssignmentOrderBy) (*types.ResourceTagAssignmentConnection, error) { + scope, err := r.authorize(ctx, obj.ID, resourcetag.ActionAssignmentGet) + if err != nil { + return nil, err + } + + pageOrderBy := page.OrderBy[coredata.ResourceTagAssignmentOrderField]{ + Field: coredata.ResourceTagAssignmentOrderFieldCreatedAt, + Direction: page.OrderDirectionAsc, + } + + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.ResourceTagAssignmentOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + pageResult, err := r.resourceTag.ListAssignmentsForTagID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list resource tag assignments", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return types.NewResourceTagAssignmentConnection(pageResult, r, obj.ID), nil +} + +// Permission is the resolver for the permission field. +func (r *resourceTagResolver) Permission(ctx context.Context, obj *types.ResourceTag, action string) (bool, error) { + return r.Resolver.Permission(ctx, obj, action) +} + +// TotalCount is the resolver for the totalCount field. +func (r *resourceTagAssignmentConnectionResolver) TotalCount(ctx context.Context, obj *types.ResourceTagAssignmentConnection) (int, error) { + scope, err := r.authorize(ctx, obj.ParentID, resourcetag.ActionAssignmentGet) + if err != nil { + return 0, err + } + + switch obj.Resolver.(type) { + case *resourceTagResolver: + count, err := r.resourceTag.CountAssignmentsForTagID(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count resource tag assignments", log.Error(err)) + return 0, gqlutils.Internal(ctx) + } + + return count, nil + } + + r.logger.ErrorCtx(ctx, "unsupported resolver for resource tag assignment connection", log.String("resolver", fmt.Sprintf("%T", obj.Resolver))) + + return 0, gqlutils.Internal(ctx) +} + +// TotalCount is the resolver for the totalCount field. +func (r *resourceTagConnectionResolver) TotalCount(ctx context.Context, obj *types.ResourceTagConnection) (int, error) { + scope, err := r.authorize(ctx, obj.ParentID, resourcetag.ActionTagList) + if err != nil { + return 0, err + } + + switch obj.Resolver.(type) { + case *organizationResolver: + count, err := r.resourceTag.CountForOrganizationID(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count resource tags", log.Error(err)) + return 0, gqlutils.Internal(ctx) + } + + return count, nil + } + + r.logger.ErrorCtx(ctx, "unsupported resolver for resource tag connection", log.String("resolver", fmt.Sprintf("%T", obj.Resolver))) + + return 0, gqlutils.Internal(ctx) +} + +// ResourceTag returns schema.ResourceTagResolver implementation. +func (r *Resolver) ResourceTag() schema.ResourceTagResolver { return &resourceTagResolver{r} } + +// ResourceTagAssignmentConnection returns schema.ResourceTagAssignmentConnectionResolver implementation. +func (r *Resolver) ResourceTagAssignmentConnection() schema.ResourceTagAssignmentConnectionResolver { + return &resourceTagAssignmentConnectionResolver{r} +} + +// ResourceTagConnection returns schema.ResourceTagConnectionResolver implementation. +func (r *Resolver) ResourceTagConnection() schema.ResourceTagConnectionResolver { + return &resourceTagConnectionResolver{r} +} + +type ( + resourceTagResolver struct{ *Resolver } + resourceTagAssignmentConnectionResolver struct{ *Resolver } + resourceTagConnectionResolver struct{ *Resolver } +) diff --git a/pkg/server/api/console/v1/types/resource_tag.go b/pkg/server/api/console/v1/types/resource_tag.go new file mode 100644 index 0000000000..21034e360b --- /dev/null +++ b/pkg/server/api/console/v1/types/resource_tag.go @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package types + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + ResourceTagOrderBy OrderBy[coredata.ResourceTagOrderField] + + ResourceTagAssignmentOrderBy OrderBy[coredata.ResourceTagAssignmentOrderField] + + ResourceTagConnection struct { + TotalCount int + Edges []*ResourceTagEdge + PageInfo PageInfo + + Resolver any + ParentID gid.GID + } + + ResourceTagAssignmentConnection struct { + TotalCount int + Edges []*ResourceTagAssignmentEdge + PageInfo PageInfo + + Resolver any + ParentID gid.GID + } +) + +func NewResourceTagConnection( + p *page.Page[*coredata.ResourceTag, coredata.ResourceTagOrderField], + parentType any, + parentID gid.GID, +) *ResourceTagConnection { + var edges = make([]*ResourceTagEdge, len(p.Data)) + + for i := range edges { + edges[i] = NewResourceTagEdge(p.Data[i], p.Cursor.OrderBy.Field) + } + + return &ResourceTagConnection{ + Edges: edges, + PageInfo: *NewPageInfo(p), + + Resolver: parentType, + ParentID: parentID, + } +} + +func NewResourceTagEdge(tag *coredata.ResourceTag, orderBy coredata.ResourceTagOrderField) *ResourceTagEdge { + return &ResourceTagEdge{ + Cursor: tag.CursorKey(orderBy), + Node: NewResourceTag(tag), + } +} + +func NewResourceTag(tag *coredata.ResourceTag) *ResourceTag { + return &ResourceTag{ + ID: tag.ID, + Organization: &Organization{ + ID: tag.OrganizationID, + }, + Key: tag.Key, + Value: tag.Value, + Color: tag.Color, + CreatedAt: tag.CreatedAt, + UpdatedAt: tag.UpdatedAt, + } +} + +func NewResourceTagAssignmentConnection( + p *page.Page[*coredata.ResourceTagAssignment, coredata.ResourceTagAssignmentOrderField], + parentType any, + parentID gid.GID, +) *ResourceTagAssignmentConnection { + var edges = make([]*ResourceTagAssignmentEdge, len(p.Data)) + + for i := range edges { + edges[i] = NewResourceTagAssignmentEdge(p.Data[i], p.Cursor.OrderBy.Field) + } + + return &ResourceTagAssignmentConnection{ + Edges: edges, + PageInfo: *NewPageInfo(p), + + Resolver: parentType, + ParentID: parentID, + } +} + +func NewResourceTagAssignmentEdge( + assignment *coredata.ResourceTagAssignment, + orderBy coredata.ResourceTagAssignmentOrderField, +) *ResourceTagAssignmentEdge { + return &ResourceTagAssignmentEdge{ + Cursor: assignment.CursorKey(orderBy), + Node: NewResourceTagAssignment(assignment), + } +} + +func NewResourceTagAssignment(assignment *coredata.ResourceTagAssignment) *ResourceTagAssignment { + return &ResourceTagAssignment{ + ID: assignment.ID, + ResourceID: assignment.ResourceID, + CreatedAt: assignment.CreatedAt, + } +} diff --git a/pkg/server/api/mcp/v1/resolver.go b/pkg/server/api/mcp/v1/resolver.go index b472cccb64..8192f01b00 100644 --- a/pkg/server/api/mcp/v1/resolver.go +++ b/pkg/server/api/mcp/v1/resolver.go @@ -43,6 +43,7 @@ import ( "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/prosemirror" "go.probo.inc/probo/pkg/resourcealias" + "go.probo.inc/probo/pkg/resourcetag" "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/authz" @@ -58,6 +59,7 @@ type Resolver struct { management *management.Service certManager *certmanager.Service resourceAlias *resourcealias.Service + resourceTag *resourcetag.Service thirdPartySvc *thirdparty.Service iamSvc *iam.Service accessReview *accessreview.Service diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 387123b03a..a1754274fc 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -25,6 +25,7 @@ import ( "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/resourcealias" + "go.probo.inc/probo/pkg/resourcetag" "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/authz" @@ -8150,3 +8151,158 @@ func (r *Resolver) DeleteCompliancePortalFrameworkTool(ctx context.Context, req DeletedCompliancePortalFrameworkID: input.ID, }, nil } + +func (r *Resolver) CreateResourceTagTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CreateResourceTagInput) (*mcp.CallToolResult, types.CreateResourceTagOutput, error) { + scope, err := r.Authorize(ctx, input.OrganizationID, resourcetag.ActionTagCreate) + if err != nil { + return nil, types.CreateResourceTagOutput{}, err + } + + tag, err := r.resourceTag.Create( + ctx, + scope, + resourcetag.CreateTagRequest{ + OrganizationID: input.OrganizationID, + Key: input.Key, + Value: input.Value, + Color: input.Color, + }, + ) + if err != nil { + return nil, types.CreateResourceTagOutput{}, fmt.Errorf("cannot create resource tag: %w", err) + } + + return nil, types.CreateResourceTagOutput{ + ResourceTag: types.NewResourceTag(tag), + }, nil +} + +func (r *Resolver) UpdateResourceTagTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateResourceTagInput) (*mcp.CallToolResult, types.UpdateResourceTagOutput, error) { + scope, err := r.Authorize(ctx, input.ID, resourcetag.ActionTagUpdate) + if err != nil { + return nil, types.UpdateResourceTagOutput{}, err + } + + tag, err := r.resourceTag.Update( + ctx, + scope, + resourcetag.UpdateTagRequest{ + ID: input.ID, + Key: input.Key, + Value: input.Value, + Color: input.Color, + }, + ) + if err != nil { + return nil, types.UpdateResourceTagOutput{}, fmt.Errorf("cannot update resource tag: %w", err) + } + + return nil, types.UpdateResourceTagOutput{ + ResourceTag: types.NewResourceTag(tag), + }, nil +} + +func (r *Resolver) DeleteResourceTagTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteResourceTagInput) (*mcp.CallToolResult, types.DeleteResourceTagOutput, error) { + scope, err := r.Authorize(ctx, input.ResourceTagID, resourcetag.ActionTagDelete) + if err != nil { + return nil, types.DeleteResourceTagOutput{}, err + } + + if err := r.resourceTag.Delete(ctx, scope, input.ResourceTagID); err != nil { + return nil, types.DeleteResourceTagOutput{}, fmt.Errorf("cannot delete resource tag: %w", err) + } + + return nil, types.DeleteResourceTagOutput{ + DeletedResourceTagID: input.ResourceTagID, + }, nil +} + +func (r *Resolver) AttachResourceTagTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AttachResourceTagInput) (*mcp.CallToolResult, types.AttachResourceTagOutput, error) { + scope, err := r.Authorize(ctx, input.ResourceID, resourcetag.ActionAssignmentAttach) + if err != nil { + return nil, types.AttachResourceTagOutput{}, err + } + + err = r.resourceTag.Attach( + ctx, + scope, + resourcetag.AttachRequest{ + ResourceID: input.ResourceID, + TagID: input.TagID, + }, + ) + if err != nil { + return nil, types.AttachResourceTagOutput{}, fmt.Errorf("cannot attach resource tag: %w", err) + } + + return nil, types.AttachResourceTagOutput{ + ResourceID: input.ResourceID, + TagID: input.TagID, + }, nil +} + +func (r *Resolver) DetachResourceTagTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DetachResourceTagInput) (*mcp.CallToolResult, types.DetachResourceTagOutput, error) { + scope, err := r.Authorize(ctx, input.ResourceID, resourcetag.ActionAssignmentDetach) + if err != nil { + return nil, types.DetachResourceTagOutput{}, err + } + + err = r.resourceTag.Detach( + ctx, + scope, + resourcetag.DetachRequest{ + ResourceID: input.ResourceID, + TagID: input.TagID, + }, + ) + if err != nil { + return nil, types.DetachResourceTagOutput{}, fmt.Errorf("cannot detach resource tag: %w", err) + } + + return nil, types.DetachResourceTagOutput{ + ResourceID: input.ResourceID, + TagID: input.TagID, + }, nil +} + +func (r *Resolver) ListResourceTagsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListResourceTagsInput) (*mcp.CallToolResult, types.ListResourceTagsOutput, error) { + scope, err := r.Authorize(ctx, input.OrganizationID, resourcetag.ActionTagList) + if err != nil { + return nil, types.ListResourceTagsOutput{}, err + } + + pageOrderBy := page.OrderBy[coredata.ResourceTagOrderField]{ + Field: coredata.ResourceTagOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + + cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) + + pageResult, err := r.resourceTag.ListForOrganizationID(ctx, scope, input.OrganizationID, cursor) + if err != nil { + return nil, types.ListResourceTagsOutput{}, fmt.Errorf("cannot list resource tags: %w", err) + } + + return nil, types.NewListResourceTagsOutput(pageResult), nil +} + +func (r *Resolver) ListResourceTagsForResourceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListResourceTagsForResourceInput) (*mcp.CallToolResult, types.ListResourceTagsForResourceOutput, error) { + scope, err := r.Authorize(ctx, input.ResourceID, resourcetag.ActionAssignmentGet) + if err != nil { + return nil, types.ListResourceTagsForResourceOutput{}, err + } + + tags, err := r.resourceTag.ListForResourceID(ctx, scope, input.ResourceID) + if err != nil { + return nil, types.ListResourceTagsForResourceOutput{}, fmt.Errorf("cannot list resource tags for resource: %w", err) + } + + result := make([]*types.ResourceTag, len(tags)) + for i, tag := range tags { + result[i] = types.NewResourceTag(tag) + } + + return nil, types.ListResourceTagsForResourceOutput{ + ResourceTags: result, + }, nil +} diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 1903cfed8e..278185a797 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -10247,6 +10247,207 @@ components: $ref: "#/components/schemas/GID" description: Resource ID whose alias was removed + ResourceTag: + type: object + required: + - id + - organization_id + - key + - value + - created_at + - updated_at + properties: + id: + $ref: "#/components/schemas/GID" + description: Resource tag ID + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + key: + type: string + description: Unique slug key for the tag within the organization + value: + type: string + description: Display value for the tag + color: + type: string + description: Optional hex color (#RGB or #RRGGBB) + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + CreateResourceTagInput: + type: object + required: + - organization_id + - key + - value + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + key: + type: string + description: Unique slug key for the tag within the organization + value: + type: string + description: Display value for the tag + color: + type: string + description: Optional hex color (#RGB or #RRGGBB) + + CreateResourceTagOutput: + type: object + required: + - resource_tag + properties: + resource_tag: + $ref: "#/components/schemas/ResourceTag" + + UpdateResourceTagInput: + type: object + required: + - id + properties: + id: + $ref: "#/components/schemas/GID" + description: Resource tag ID + key: + type: string + description: Unique slug key for the tag within the organization + value: + type: string + description: Display value for the tag + color: + type: string + description: Optional hex color (#RGB or #RRGGBB); empty string clears + + UpdateResourceTagOutput: + type: object + required: + - resource_tag + properties: + resource_tag: + $ref: "#/components/schemas/ResourceTag" + + DeleteResourceTagInput: + type: object + required: + - resource_tag_id + properties: + resource_tag_id: + $ref: "#/components/schemas/GID" + description: Resource tag ID to delete + + DeleteResourceTagOutput: + type: object + required: + - deleted_resource_tag_id + properties: + deleted_resource_tag_id: + $ref: "#/components/schemas/GID" + description: Deleted resource tag ID + + AttachResourceTagInput: + type: object + required: + - resource_id + - tag_id + properties: + resource_id: + $ref: "#/components/schemas/GID" + description: ID of the resource to tag + tag_id: + $ref: "#/components/schemas/GID" + description: Resource tag ID + + AttachResourceTagOutput: + type: object + required: + - resource_id + - tag_id + properties: + resource_id: + $ref: "#/components/schemas/GID" + tag_id: + $ref: "#/components/schemas/GID" + + DetachResourceTagInput: + type: object + required: + - resource_id + - tag_id + properties: + resource_id: + $ref: "#/components/schemas/GID" + description: ID of the resource to untag + tag_id: + $ref: "#/components/schemas/GID" + description: Resource tag ID + + DetachResourceTagOutput: + type: object + required: + - resource_id + - tag_id + properties: + resource_id: + $ref: "#/components/schemas/GID" + tag_id: + $ref: "#/components/schemas/GID" + + ListResourceTagsInput: + type: object + required: + - organization_id + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + size: + type: integer + description: Page size + cursor: + $ref: "#/components/schemas/CursorKey" + description: Page cursor + + ListResourceTagsOutput: + type: object + required: + - resource_tags + properties: + resource_tags: + type: array + items: + $ref: "#/components/schemas/ResourceTag" + next_cursor: + anyOf: + - $ref: "#/components/schemas/CursorKey" + - type: "null" + description: Next page cursor + + ListResourceTagsForResourceInput: + type: object + required: + - resource_id + properties: + resource_id: + $ref: "#/components/schemas/GID" + description: Resource ID + + ListResourceTagsForResourceOutput: + type: object + required: + - resource_tags + properties: + resource_tags: + type: array + items: + $ref: "#/components/schemas/ResourceTag" + ListCompliancePortalFilesInput: type: object required: @@ -16157,6 +16358,90 @@ tools: $ref: "#/components/schemas/RemoveResourceAliasInput" outputSchema: $ref: "#/components/schemas/RemoveResourceAliasOutput" + - name: createResourceTag + title: Create Resource Tag + description: Create an organization resource tag + hints: + readonly: false + destructive: false + idempotent: false + openWorld: false + inputSchema: + $ref: "#/components/schemas/CreateResourceTagInput" + outputSchema: + $ref: "#/components/schemas/CreateResourceTagOutput" + - name: updateResourceTag + title: Update Resource Tag + description: Update an organization resource tag + hints: + readonly: false + destructive: false + idempotent: true + openWorld: false + inputSchema: + $ref: "#/components/schemas/UpdateResourceTagInput" + outputSchema: + $ref: "#/components/schemas/UpdateResourceTagOutput" + - name: deleteResourceTag + title: Delete Resource Tag + description: Delete an organization resource tag and its assignments + hints: + readonly: false + destructive: true + idempotent: true + openWorld: false + inputSchema: + $ref: "#/components/schemas/DeleteResourceTagInput" + outputSchema: + $ref: "#/components/schemas/DeleteResourceTagOutput" + - name: attachResourceTag + title: Attach Resource Tag + description: Attach a resource tag to a resource + hints: + readonly: false + destructive: false + idempotent: true + openWorld: false + inputSchema: + $ref: "#/components/schemas/AttachResourceTagInput" + outputSchema: + $ref: "#/components/schemas/AttachResourceTagOutput" + - name: detachResourceTag + title: Detach Resource Tag + description: Detach a resource tag from a resource + hints: + readonly: false + destructive: true + idempotent: true + openWorld: false + inputSchema: + $ref: "#/components/schemas/DetachResourceTagInput" + outputSchema: + $ref: "#/components/schemas/DetachResourceTagOutput" + - name: listResourceTags + title: List Resource Tags + description: List organization resource tags + hints: + readonly: true + destructive: false + idempotent: true + openWorld: false + inputSchema: + $ref: "#/components/schemas/ListResourceTagsInput" + outputSchema: + $ref: "#/components/schemas/ListResourceTagsOutput" + - name: listResourceTagsForResource + title: List Resource Tags For Resource + description: List tags attached to a resource + hints: + readonly: true + destructive: false + idempotent: true + openWorld: false + inputSchema: + $ref: "#/components/schemas/ListResourceTagsForResourceInput" + outputSchema: + $ref: "#/components/schemas/ListResourceTagsForResourceOutput" - name: listCompliancePortalFiles title: List Compliance Portal Files description: List all files for the compliance portal diff --git a/pkg/server/api/mcp/v1/types/resource_tag.go b/pkg/server/api/mcp/v1/types/resource_tag.go new file mode 100644 index 0000000000..10d0ade064 --- /dev/null +++ b/pkg/server/api/mcp/v1/types/resource_tag.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package types + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/page" +) + +func NewResourceTag(tag *coredata.ResourceTag) *ResourceTag { + return &ResourceTag{ + ID: tag.ID, + OrganizationID: tag.OrganizationID, + Key: tag.Key, + Value: tag.Value, + Color: tag.Color, + CreatedAt: tag.CreatedAt, + UpdatedAt: tag.UpdatedAt, + } +} + +func NewListResourceTagsOutput(p *page.Page[*coredata.ResourceTag, coredata.ResourceTagOrderField]) ListResourceTagsOutput { + tags := make([]*ResourceTag, 0, len(p.Data)) + for _, tag := range p.Data { + tags = append(tags, NewResourceTag(tag)) + } + + var nextCursor *page.CursorKey + + if len(p.Data) > 0 { + cursorKey := p.Data[len(p.Data)-1].CursorKey(p.Cursor.OrderBy.Field) + nextCursor = &cursorKey + } + + return ListResourceTagsOutput{ + NextCursor: nextCursor, + ResourceTags: tags, + } +} diff --git a/pkg/server/api/mcp/v1/v1_handler.go b/pkg/server/api/mcp/v1/v1_handler.go index a255e33221..8e115f5854 100644 --- a/pkg/server/api/mcp/v1/v1_handler.go +++ b/pkg/server/api/mcp/v1/v1_handler.go @@ -38,6 +38,7 @@ import ( "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/resourcealias" + "go.probo.inc/probo/pkg/resourcetag" "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/mcp/mcputils" @@ -51,6 +52,7 @@ func NewMux( managementSvc *management.Service, certManagerSvc *certmanager.Service, resourceAliasSvc *resourcealias.Service, + resourceTagSvc *resourcetag.Service, thirdPartySvc *thirdparty.Service, iamSvc *iam.Service, accessReviewSvc *accessreview.Service, @@ -71,6 +73,7 @@ func NewMux( management: managementSvc, certManager: certManagerSvc, resourceAlias: resourceAliasSvc, + resourceTag: resourceTagSvc, thirdPartySvc: thirdPartySvc, iamSvc: iamSvc, accessReview: accessReviewSvc, diff --git a/pkg/server/server.go b/pkg/server/server.go index fc5d081569..1ca1879a97 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -43,6 +43,7 @@ import ( "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/resourcealias" + "go.probo.inc/probo/pkg/resourcetag" "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/securecookie" "go.probo.inc/probo/pkg/server/api" @@ -61,6 +62,7 @@ type Config struct { ExtraHeaderFields map[string]string Probo *probo.Service ResourceAlias *resourcealias.Service + ResourceTag *resourcetag.Service File *filemanager.Service IAM *iam.Service Visitor *visitor.Service @@ -104,6 +106,7 @@ func NewServer(cfg Config) (*Server, error) { AllowedOrigins: cfg.AllowedOrigins, Probo: cfg.Probo, ResourceAlias: cfg.ResourceAlias, + ResourceTag: cfg.ResourceTag, File: cfg.File, IAM: cfg.IAM, Visitor: cfg.Visitor, diff --git a/pkg/validator/validator_format.go b/pkg/validator/validator_format.go index 8d4adb5faf..c9f41637b6 100644 --- a/pkg/validator/validator_format.go +++ b/pkg/validator/validator_format.go @@ -31,8 +31,9 @@ import ( ) var ( - domainRegex = regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) - slugRegex = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + domainRegex = regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) + slugRegex = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + hexColorRegex = regexp.MustCompile(`^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$`) ) // URL validates that a string is a valid URL with http or https scheme. @@ -217,6 +218,31 @@ func Slug(maxLen int) ValidatorFunc { } } +// HexColor validates that a string is a CSS hex color (#RGB or #RRGGBB). +func HexColor() ValidatorFunc { + return func(value any) *ValidationError { + actualValue, isNil := dereferenceValue(value) + if isNil { + return nil + } + + str, ok := actualValue.(string) + if !ok { + return newValidationError(ErrorCodeInvalidFormat, "value must be a string") + } + + if str == "" { + return nil + } + + if !hexColorRegex.MatchString(str) { + return newValidationError(ErrorCodeInvalidFormat, "color must be a hex value (#RGB or #RRGGBB)") + } + + return nil + } +} + // Domain validates that a string is a valid domain name. func Domain() ValidatorFunc { return func(value any) *ValidationError { diff --git a/pkg/validator/validator_format_test.go b/pkg/validator/validator_format_test.go index 2934ca7505..0f49f065de 100644 --- a/pkg/validator/validator_format_test.go +++ b/pkg/validator/validator_format_test.go @@ -224,6 +224,39 @@ func TestSlug(t *testing.T) { } } +func TestHexColor(t *testing.T) { + tests := []struct { + name string + value any + wantError bool + }{ + {"valid #RGB", "#0f0", false}, + {"valid #RRGGBB", "#00ff00", false}, + {"valid uppercase", "#ABC", false}, + {"valid mixed case", "#aBcDeF", false}, + {"empty string", "", false}, + {"nil pointer", (*string)(nil), false}, + {"missing hash", "00ff00", true}, + {"too short", "#0f", true}, + {"too long", "#00ff00a", true}, + {"invalid chars", "#gg0000", true}, + {"non-string", 123, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := HexColor()(tt.value) + if (err != nil) != tt.wantError { + t.Errorf("HexColor() error = %v, wantError %v", err, tt.wantError) + } + + if err != nil && err.Code != ErrorCodeInvalidFormat { + t.Errorf("Expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code) + } + }) + } +} + func TestDomain(t *testing.T) { t.Run("valid domain", func(t *testing.T) { str := "example.com"