From bc5bbdae81e796da45c42c9d1190cb8092e7cabe Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Wed, 28 Jan 2026 23:42:06 -0800 Subject: [PATCH] Add scim bridge with connector Signed-off-by: Bryan Frimin --- .../iam/ConnectorListFragment.graphql.ts | 53 + ...orkspaceConnectorDeleteMutation.graphql.ts | 93 ++ ...oogleWorkspaceConnectorFragment.graphql.ts | 90 ++ ...SCIMConfigurationCreateMutation.graphql.ts | 3 +- .../iam/SCIMConfigurationFragment.graphql.ts | 83 +- ...CreateSCIMConfigurationMutation.graphql.ts | 120 ++ .../iam/SCIMSettingsPageQuery.graphql.ts | 146 +- .../settings/SCIMSettingsPage.tsx | 148 +- .../settings/_components/ConnectorList.tsx | 32 + .../_components/GoogleWorkspaceConnector.tsx | 171 +++ .../_components/SCIMConfiguration.tsx | 55 +- apps/console/vite.config.ts | 4 +- cfg/dev.yaml | 17 +- go.mod | 13 +- go.sum | 24 +- pkg/connector/connector.go | 2 +- pkg/connector/oauth2.go | 63 +- pkg/connector/registry.go | 4 +- pkg/coredata/connector.go | 104 ++ pkg/coredata/connector_provider.go | 6 +- pkg/coredata/entity_type_reg.go | 3 + pkg/coredata/migrations/20260127T120000Z.sql | 12 + pkg/coredata/migrations/20260129T041655Z.sql | 1 + pkg/coredata/scim_bridge.go | 323 +++++ pkg/coredata/scim_bridge_order_field.go | 40 + pkg/coredata/scim_bridge_state.go | 60 + pkg/coredata/scim_bridge_type.go | 54 + pkg/coredata/scim_configuration.go | 98 +- pkg/iam/errors.go | 20 + pkg/iam/iam_actions.go | 8 + pkg/iam/organization_service.go | 314 +++++ pkg/probo/actions.go | 4 + pkg/probo/connector_service.go | 46 + pkg/probo/policies.go | 2 +- pkg/probod/connector_config.go | 26 +- pkg/server/api/connect/v1/schema.graphql | 48 + pkg/server/api/connect/v1/schema/schema.go | 1199 ++++++++++++++++- pkg/server/api/connect/v1/types/bridge.go | 38 + pkg/server/api/connect/v1/types/connector.go | 26 + .../connect/v1/types/scim_configuration.go | 18 +- pkg/server/api/connect/v1/types/types.go | 30 +- pkg/server/api/connect/v1/v1_resolver.go | 113 +- pkg/server/api/console/v1/resolver.go | 43 +- 43 files changed, 3568 insertions(+), 189 deletions(-) create mode 100644 apps/console/src/__generated__/iam/ConnectorListFragment.graphql.ts create mode 100644 apps/console/src/__generated__/iam/GoogleWorkspaceConnectorDeleteMutation.graphql.ts create mode 100644 apps/console/src/__generated__/iam/GoogleWorkspaceConnectorFragment.graphql.ts create mode 100644 apps/console/src/__generated__/iam/SCIMSettingsPageCreateSCIMConfigurationMutation.graphql.ts create mode 100644 apps/console/src/pages/iam/organizations/settings/_components/ConnectorList.tsx create mode 100644 apps/console/src/pages/iam/organizations/settings/_components/GoogleWorkspaceConnector.tsx create mode 100644 pkg/coredata/migrations/20260127T120000Z.sql create mode 100644 pkg/coredata/migrations/20260129T041655Z.sql create mode 100644 pkg/coredata/scim_bridge.go create mode 100644 pkg/coredata/scim_bridge_order_field.go create mode 100644 pkg/coredata/scim_bridge_state.go create mode 100644 pkg/coredata/scim_bridge_type.go create mode 100644 pkg/server/api/connect/v1/types/bridge.go create mode 100644 pkg/server/api/connect/v1/types/connector.go diff --git a/apps/console/src/__generated__/iam/ConnectorListFragment.graphql.ts b/apps/console/src/__generated__/iam/ConnectorListFragment.graphql.ts new file mode 100644 index 000000000..b17929456 --- /dev/null +++ b/apps/console/src/__generated__/iam/ConnectorListFragment.graphql.ts @@ -0,0 +1,53 @@ +/** + * @generated SignedSource<<042ba763926f3dddfcf4f9d673ae5201>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ReaderFragment } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type ConnectorListFragment$data = { + readonly scimConfiguration: { + readonly " $fragmentSpreads": FragmentRefs<"GoogleWorkspaceConnectorFragment">; + } | null | undefined; + readonly " $fragmentType": "ConnectorListFragment"; +}; +export type ConnectorListFragment$key = { + readonly " $data"?: ConnectorListFragment$data; + readonly " $fragmentSpreads": FragmentRefs<"ConnectorListFragment">; +}; + +const node: ReaderFragment = { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "ConnectorListFragment", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "SCIMConfiguration", + "kind": "LinkedField", + "name": "scimConfiguration", + "plural": false, + "selections": [ + { + "args": null, + "kind": "FragmentSpread", + "name": "GoogleWorkspaceConnectorFragment" + } + ], + "storageKey": null + } + ], + "type": "Organization", + "abstractKey": null +}; + +(node as any).hash = "32fd94ab5080bb8deba95ab24b545fc1"; + +export default node; diff --git a/apps/console/src/__generated__/iam/GoogleWorkspaceConnectorDeleteMutation.graphql.ts b/apps/console/src/__generated__/iam/GoogleWorkspaceConnectorDeleteMutation.graphql.ts new file mode 100644 index 000000000..5d24932aa --- /dev/null +++ b/apps/console/src/__generated__/iam/GoogleWorkspaceConnectorDeleteMutation.graphql.ts @@ -0,0 +1,93 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type DeleteSCIMConfigurationInput = { + organizationId: string; + scimConfigurationId: string; +}; +export type GoogleWorkspaceConnectorDeleteMutation$variables = { + input: DeleteSCIMConfigurationInput; +}; +export type GoogleWorkspaceConnectorDeleteMutation$data = { + readonly deleteSCIMConfiguration: { + readonly deletedScimConfigurationId: string; + } | null | undefined; +}; +export type GoogleWorkspaceConnectorDeleteMutation = { + response: GoogleWorkspaceConnectorDeleteMutation$data; + variables: GoogleWorkspaceConnectorDeleteMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } + ], + "concreteType": "DeleteSCIMConfigurationPayload", + "kind": "LinkedField", + "name": "deleteSCIMConfiguration", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "deletedScimConfigurationId", + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "GoogleWorkspaceConnectorDeleteMutation", + "selections": (v1/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "GoogleWorkspaceConnectorDeleteMutation", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "8fa5927c728a768a19fd163d162facac", + "id": null, + "metadata": {}, + "name": "GoogleWorkspaceConnectorDeleteMutation", + "operationKind": "mutation", + "text": "mutation GoogleWorkspaceConnectorDeleteMutation(\n $input: DeleteSCIMConfigurationInput!\n) {\n deleteSCIMConfiguration(input: $input) {\n deletedScimConfigurationId\n }\n}\n" + } +}; +})(); + +(node as any).hash = "fc1502ef215040eda187b2f782adb530"; + +export default node; diff --git a/apps/console/src/__generated__/iam/GoogleWorkspaceConnectorFragment.graphql.ts b/apps/console/src/__generated__/iam/GoogleWorkspaceConnectorFragment.graphql.ts new file mode 100644 index 000000000..e990e97b7 --- /dev/null +++ b/apps/console/src/__generated__/iam/GoogleWorkspaceConnectorFragment.graphql.ts @@ -0,0 +1,90 @@ +/** + * @generated SignedSource<<2beb895d75d5cbcbd64a59387e35c281>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ReaderFragment } from 'relay-runtime'; +export type ConnectorProvider = "GOOGLE_WORKSPACE" | "SLACK"; +import { FragmentRefs } from "relay-runtime"; +export type GoogleWorkspaceConnectorFragment$data = { + readonly bridge: { + readonly connector: { + readonly createdAt: string; + readonly id: string; + readonly provider: ConnectorProvider; + } | null | undefined; + } | null | undefined; + readonly id: string; + readonly " $fragmentType": "GoogleWorkspaceConnectorFragment"; +}; +export type GoogleWorkspaceConnectorFragment$key = { + readonly " $data"?: GoogleWorkspaceConnectorFragment$data; + readonly " $fragmentSpreads": FragmentRefs<"GoogleWorkspaceConnectorFragment">; +}; + +const node: ReaderFragment = (function(){ +var v0 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}; +return { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "GoogleWorkspaceConnectorFragment", + "selections": [ + (v0/*: any*/), + { + "alias": null, + "args": null, + "concreteType": "SCIMBridge", + "kind": "LinkedField", + "name": "bridge", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Connector", + "kind": "LinkedField", + "name": "connector", + "plural": false, + "selections": [ + (v0/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "provider", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "createdAt", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "type": "SCIMConfiguration", + "abstractKey": null +}; +})(); + +(node as any).hash = "180a97ae989c22060d815280ec2f7b67"; + +export default node; diff --git a/apps/console/src/__generated__/iam/SCIMConfigurationCreateMutation.graphql.ts b/apps/console/src/__generated__/iam/SCIMConfigurationCreateMutation.graphql.ts index dbe85022d..cf331f75f 100644 --- a/apps/console/src/__generated__/iam/SCIMConfigurationCreateMutation.graphql.ts +++ b/apps/console/src/__generated__/iam/SCIMConfigurationCreateMutation.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<481d9c3c647c11a95d68ff7e4a489a24>> + * @generated SignedSource<<29e2f3791be9e59a1c61033e31eb0134>> * @lightSyntaxTransform * @nogrep */ @@ -10,6 +10,7 @@ import { ConcreteRequest } from 'relay-runtime'; export type CreateSCIMConfigurationInput = { + connectorId?: string | null | undefined; organizationId: string; }; export type SCIMConfigurationCreateMutation$variables = { diff --git a/apps/console/src/__generated__/iam/SCIMConfigurationFragment.graphql.ts b/apps/console/src/__generated__/iam/SCIMConfigurationFragment.graphql.ts index 8e8676789..7ef3ca5d0 100644 --- a/apps/console/src/__generated__/iam/SCIMConfigurationFragment.graphql.ts +++ b/apps/console/src/__generated__/iam/SCIMConfigurationFragment.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<17691f5400c05fd0281c9320b9fb86b9>> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -11,8 +11,15 @@ import { ReaderFragment } from 'relay-runtime'; import { FragmentRefs } from "relay-runtime"; export type SCIMConfigurationFragment$data = { - readonly endpointUrl: string; - readonly id: string; + readonly canCreateSCIMConfiguration: boolean; + readonly canDeleteSCIMConfiguration: boolean; + readonly scimConfiguration: { + readonly bridge: { + readonly id: string; + } | null | undefined; + readonly endpointUrl: string; + readonly id: string; + } | null | undefined; readonly " $fragmentType": "SCIMConfigurationFragment"; }; export type SCIMConfigurationFragment$key = { @@ -20,31 +27,83 @@ export type SCIMConfigurationFragment$key = { readonly " $fragmentSpreads": FragmentRefs<"SCIMConfigurationFragment">; }; -const node: ReaderFragment = { +const node: ReaderFragment = (function(){ +var v0 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}; +return { "argumentDefinitions": [], "kind": "Fragment", "metadata": null, "name": "SCIMConfigurationFragment", "selections": [ { - "alias": null, - "args": null, + "alias": "canCreateSCIMConfiguration", + "args": [ + { + "kind": "Literal", + "name": "action", + "value": "iam:scim-configuration:create" + } + ], "kind": "ScalarField", - "name": "id", - "storageKey": null + "name": "permission", + "storageKey": "permission(action:\"iam:scim-configuration:create\")" + }, + { + "alias": "canDeleteSCIMConfiguration", + "args": [ + { + "kind": "Literal", + "name": "action", + "value": "iam:scim-configuration:delete" + } + ], + "kind": "ScalarField", + "name": "permission", + "storageKey": "permission(action:\"iam:scim-configuration:delete\")" }, { "alias": null, "args": null, - "kind": "ScalarField", - "name": "endpointUrl", + "concreteType": "SCIMConfiguration", + "kind": "LinkedField", + "name": "scimConfiguration", + "plural": false, + "selections": [ + (v0/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "endpointUrl", + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "SCIMBridge", + "kind": "LinkedField", + "name": "bridge", + "plural": false, + "selections": [ + (v0/*: any*/) + ], + "storageKey": null + } + ], "storageKey": null } ], - "type": "SCIMConfiguration", + "type": "Organization", "abstractKey": null }; +})(); -(node as any).hash = "5bd76da1abe24699f9895857cda748b1"; +(node as any).hash = "5b128ae2e680a0ffe0dd931e4e86f52e"; export default node; diff --git a/apps/console/src/__generated__/iam/SCIMSettingsPageCreateSCIMConfigurationMutation.graphql.ts b/apps/console/src/__generated__/iam/SCIMSettingsPageCreateSCIMConfigurationMutation.graphql.ts new file mode 100644 index 000000000..3d5775009 --- /dev/null +++ b/apps/console/src/__generated__/iam/SCIMSettingsPageCreateSCIMConfigurationMutation.graphql.ts @@ -0,0 +1,120 @@ +/** + * @generated SignedSource<<4b9c832eb09c31751c1da609cda983ba>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type CreateSCIMConfigurationInput = { + connectorId?: string | null | undefined; + organizationId: string; +}; +export type SCIMSettingsPageCreateSCIMConfigurationMutation$variables = { + input: CreateSCIMConfigurationInput; +}; +export type SCIMSettingsPageCreateSCIMConfigurationMutation$data = { + readonly createSCIMConfiguration: { + readonly scimBridge: { + readonly id: string; + } | null | undefined; + readonly scimConfiguration: { + readonly id: string; + }; + } | null | undefined; +}; +export type SCIMSettingsPageCreateSCIMConfigurationMutation = { + response: SCIMSettingsPageCreateSCIMConfigurationMutation$data; + variables: SCIMSettingsPageCreateSCIMConfigurationMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + } +], +v2 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } + ], + "concreteType": "CreateSCIMConfigurationPayload", + "kind": "LinkedField", + "name": "createSCIMConfiguration", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "SCIMConfiguration", + "kind": "LinkedField", + "name": "scimConfiguration", + "plural": false, + "selections": (v1/*: any*/), + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "SCIMBridge", + "kind": "LinkedField", + "name": "scimBridge", + "plural": false, + "selections": (v1/*: any*/), + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "SCIMSettingsPageCreateSCIMConfigurationMutation", + "selections": (v2/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "SCIMSettingsPageCreateSCIMConfigurationMutation", + "selections": (v2/*: any*/) + }, + "params": { + "cacheID": "281c9e39b96e2e56c3f4379f8dd8dc30", + "id": null, + "metadata": {}, + "name": "SCIMSettingsPageCreateSCIMConfigurationMutation", + "operationKind": "mutation", + "text": "mutation SCIMSettingsPageCreateSCIMConfigurationMutation(\n $input: CreateSCIMConfigurationInput!\n) {\n createSCIMConfiguration(input: $input) {\n scimConfiguration {\n id\n }\n scimBridge {\n id\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "841dfdb5ce8a226c7e683090c75951f0"; + +export default node; diff --git a/apps/console/src/__generated__/iam/SCIMSettingsPageQuery.graphql.ts b/apps/console/src/__generated__/iam/SCIMSettingsPageQuery.graphql.ts index e8b7a7d78..9c67c6c0b 100644 --- a/apps/console/src/__generated__/iam/SCIMSettingsPageQuery.graphql.ts +++ b/apps/console/src/__generated__/iam/SCIMSettingsPageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<5507ea6452d9065f29d74bf685ea3777>> + * @generated SignedSource<<8e3a5e8f3e57e3097149f28537f50e31>> * @lightSyntaxTransform * @nogrep */ @@ -16,12 +16,15 @@ export type SCIMSettingsPageQuery$variables = { export type SCIMSettingsPageQuery$data = { readonly organization: { readonly __typename: "Organization"; - readonly canCreateSCIMConfiguration: boolean; - readonly canDeleteSCIMConfiguration: boolean; readonly id: string; readonly scimConfiguration: { - readonly " $fragmentSpreads": FragmentRefs<"SCIMConfigurationFragment" | "SCIMEventListFragment">; + readonly bridge: { + readonly id: string; + } | null | undefined; + readonly id: string; + readonly " $fragmentSpreads": FragmentRefs<"SCIMEventListFragment">; } | null | undefined; + readonly " $fragmentSpreads": FragmentRefs<"ConnectorListFragment" | "SCIMConfigurationFragment">; } | { // This will never be '%other', but we need some // value in case none of the concrete values match. @@ -63,32 +66,13 @@ v3 = { "storageKey": null }, v4 = { - "alias": "canCreateSCIMConfiguration", - "args": [ - { - "kind": "Literal", - "name": "action", - "value": "iam:scim-configuration:create" - } - ], + "alias": null, + "args": null, "kind": "ScalarField", - "name": "permission", - "storageKey": "permission(action:\"iam:scim-configuration:create\")" + "name": "createdAt", + "storageKey": null }, -v5 = { - "alias": "canDeleteSCIMConfiguration", - "args": [ - { - "kind": "Literal", - "name": "action", - "value": "iam:scim-configuration:delete" - } - ], - "kind": "ScalarField", - "name": "permission", - "storageKey": "permission(action:\"iam:scim-configuration:delete\")" -}, -v6 = [ +v5 = [ { "kind": "Literal", "name": "first", @@ -117,8 +101,6 @@ return { "kind": "InlineFragment", "selections": [ (v3/*: any*/), - (v4/*: any*/), - (v5/*: any*/), { "alias": null, "args": null, @@ -127,10 +109,18 @@ return { "name": "scimConfiguration", "plural": false, "selections": [ + (v3/*: any*/), { + "alias": null, "args": null, - "kind": "FragmentSpread", - "name": "SCIMConfigurationFragment" + "concreteType": "SCIMBridge", + "kind": "LinkedField", + "name": "bridge", + "plural": false, + "selections": [ + (v3/*: any*/) + ], + "storageKey": null }, { "args": null, @@ -139,6 +129,16 @@ return { } ], "storageKey": null + }, + { + "args": null, + "kind": "FragmentSpread", + "name": "SCIMConfigurationFragment" + }, + { + "args": null, + "kind": "FragmentSpread", + "name": "ConnectorListFragment" } ], "type": "Organization", @@ -172,8 +172,6 @@ return { { "kind": "InlineFragment", "selections": [ - (v4/*: any*/), - (v5/*: any*/), { "alias": null, "args": null, @@ -186,13 +184,38 @@ return { { "alias": null, "args": null, - "kind": "ScalarField", - "name": "endpointUrl", + "concreteType": "SCIMBridge", + "kind": "LinkedField", + "name": "bridge", + "plural": false, + "selections": [ + (v3/*: any*/), + { + "alias": null, + "args": null, + "concreteType": "Connector", + "kind": "LinkedField", + "name": "connector", + "plural": false, + "selections": [ + (v3/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "provider", + "storageKey": null + }, + (v4/*: any*/) + ], + "storageKey": null + } + ], "storageKey": null }, { "alias": null, - "args": (v6/*: any*/), + "args": (v5/*: any*/), "concreteType": "SCIMEventConnection", "kind": "LinkedField", "name": "events", @@ -250,13 +273,7 @@ return { "name": "ipAddress", "storageKey": null }, - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "createdAt", - "storageKey": null - }, + (v4/*: any*/), { "alias": null, "args": null, @@ -346,15 +363,48 @@ return { }, { "alias": null, - "args": (v6/*: any*/), + "args": (v5/*: any*/), "filters": null, "handle": "connection", "key": "SCIMEventListFragment_events", "kind": "LinkedHandle", "name": "events" + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "endpointUrl", + "storageKey": null } ], "storageKey": null + }, + { + "alias": "canCreateSCIMConfiguration", + "args": [ + { + "kind": "Literal", + "name": "action", + "value": "iam:scim-configuration:create" + } + ], + "kind": "ScalarField", + "name": "permission", + "storageKey": "permission(action:\"iam:scim-configuration:create\")" + }, + { + "alias": "canDeleteSCIMConfiguration", + "args": [ + { + "kind": "Literal", + "name": "action", + "value": "iam:scim-configuration:delete" + } + ], + "kind": "ScalarField", + "name": "permission", + "storageKey": "permission(action:\"iam:scim-configuration:delete\")" } ], "type": "Organization", @@ -366,16 +416,16 @@ return { ] }, "params": { - "cacheID": "89bcad16c50ccf4ef4afd0345b02fe4a", + "cacheID": "eabd000a44bc720b07342557eedac872", "id": null, "metadata": {}, "name": "SCIMSettingsPageQuery", "operationKind": "query", - "text": "query SCIMSettingsPageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n canCreateSCIMConfiguration: permission(action: \"iam:scim-configuration:create\")\n canDeleteSCIMConfiguration: permission(action: \"iam:scim-configuration:delete\")\n scimConfiguration {\n ...SCIMConfigurationFragment\n ...SCIMEventListFragment\n id\n }\n }\n id\n }\n}\n\nfragment SCIMConfigurationFragment on SCIMConfiguration {\n id\n endpointUrl\n}\n\nfragment SCIMEventListFragment on SCIMConfiguration {\n events(first: 20) {\n edges {\n node {\n id\n ...SCIMEventListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment SCIMEventListItemFragment on SCIMEvent {\n id\n method\n path\n statusCode\n errorMessage\n ipAddress\n createdAt\n membership {\n id\n profile {\n fullName\n id\n }\n }\n}\n" + "text": "query SCIMSettingsPageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n scimConfiguration {\n id\n bridge {\n id\n }\n ...SCIMEventListFragment\n }\n ...SCIMConfigurationFragment\n ...ConnectorListFragment\n }\n id\n }\n}\n\nfragment ConnectorListFragment on Organization {\n scimConfiguration {\n ...GoogleWorkspaceConnectorFragment\n id\n }\n}\n\nfragment GoogleWorkspaceConnectorFragment on SCIMConfiguration {\n id\n bridge {\n connector {\n id\n provider\n createdAt\n }\n id\n }\n}\n\nfragment SCIMConfigurationFragment on Organization {\n canCreateSCIMConfiguration: permission(action: \"iam:scim-configuration:create\")\n canDeleteSCIMConfiguration: permission(action: \"iam:scim-configuration:delete\")\n scimConfiguration {\n id\n endpointUrl\n bridge {\n id\n }\n }\n}\n\nfragment SCIMEventListFragment on SCIMConfiguration {\n events(first: 20) {\n edges {\n node {\n id\n ...SCIMEventListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment SCIMEventListItemFragment on SCIMEvent {\n id\n method\n path\n statusCode\n errorMessage\n ipAddress\n createdAt\n membership {\n id\n profile {\n fullName\n id\n }\n }\n}\n" } }; })(); -(node as any).hash = "051cceb15c9eaf30694189c259b6d715"; +(node as any).hash = "9db69676a14d2a6c4062fee58ce728e1"; export default node; diff --git a/apps/console/src/pages/iam/organizations/settings/SCIMSettingsPage.tsx b/apps/console/src/pages/iam/organizations/settings/SCIMSettingsPage.tsx index b0f4339ed..d5af85673 100644 --- a/apps/console/src/pages/iam/organizations/settings/SCIMSettingsPage.tsx +++ b/apps/console/src/pages/iam/organizations/settings/SCIMSettingsPage.tsx @@ -1,8 +1,14 @@ import { useTranslate } from "@probo/i18n"; -import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay"; +import { Spinner } from "@probo/ui"; +import { useEffect, useRef } from "react"; +import { graphql, type PreloadedQuery, + useMutation, usePreloadedQuery } from "react-relay"; +import { useSearchParams } from "react-router"; +import type { SCIMSettingsPageCreateSCIMConfigurationMutation } from "#/__generated__/iam/SCIMSettingsPageCreateSCIMConfigurationMutation.graphql"; import type { SCIMSettingsPageQuery } from "#/__generated__/iam/SCIMSettingsPageQuery.graphql"; +import { ConnectorList } from "./_components/ConnectorList"; import { SCIMConfiguration } from "./_components/SCIMConfiguration"; import { SCIMEventList } from "./_components/SCIMEventList"; @@ -12,17 +18,32 @@ export const scimSettingsPageQuery = graphql` __typename ... on Organization { id - canCreateSCIMConfiguration: permission( - action: "iam:scim-configuration:create" - ) - canDeleteSCIMConfiguration: permission( - action: "iam:scim-configuration:delete" - ) scimConfiguration { - ...SCIMConfigurationFragment + id + bridge { + id + } ...SCIMEventListFragment } + + ...SCIMConfigurationFragment + ...ConnectorListFragment + } + } + } +`; + +const createSCIMConfigurationMutation = graphql` + mutation SCIMSettingsPageCreateSCIMConfigurationMutation( + $input: CreateSCIMConfigurationInput! + ) { + createSCIMConfiguration(input: $input) { + scimConfiguration { + id + } + scimBridge { + id } } } @@ -33,26 +54,117 @@ export function SCIMSettingsPage(props: { }) { const { queryRef } = props; const { __ } = useTranslate(); + const [searchParams, setSearchParams] = useSearchParams(); + const connectorId = searchParams.get("connector_id"); + const mutationTriggeredRef = useRef(false); const { organization } = usePreloadedQuery(scimSettingsPageQuery, queryRef); if (organization.__typename !== "Organization") { throw new Error("invalid node type"); } + const [createSCIMConfiguration] + = useMutation( + createSCIMConfigurationMutation, + ); + + // Auto-create SCIM configuration and bridge when connector_id is in URL + useEffect(() => { + if (!connectorId || mutationTriggeredRef.current) return; + + // Don't create if SCIM config already exists + if (organization.scimConfiguration?.id) { + setSearchParams((params: URLSearchParams) => { + params.delete("connector_id"); + return params; + }); + return; + } + + mutationTriggeredRef.current = true; + + createSCIMConfiguration({ + variables: { + input: { + organizationId: organization.id, + connectorId: connectorId, + }, + }, + onCompleted: () => { + const url = new URL(window.location.href); + url.searchParams.delete("connector_id"); + window.location.href = url.toString(); + }, + onError: (error) => { + console.error("Failed to create SCIM configuration:", error); + mutationTriggeredRef.current = false; + setSearchParams((params: URLSearchParams) => { + params.delete("connector_id"); + return params; + }); + }, + }); + }, [ + connectorId, + organization.id, + organization.scimConfiguration?.id, + createSCIMConfiguration, + setSearchParams, + ]); + + // Show loader while creating SCIM configuration + if (connectorId) { + return ( +
+ +
+ ); + } + + // Check if connected via Identity Provider (SCIM config has a bridge) + const hasIdentityProvider = !!organization.scimConfiguration?.bridge; + + // Check if Manual SCIM is configured (SCIM config exists but no bridge) + const hasManualScim = !!organization.scimConfiguration && !organization.scimConfiguration.bridge; + + // Show Identity Provider section when: + // - No SCIM config yet (user can connect) + // - Or SCIM config with bridge (already connected via IdP) + const showIdentityProviderSection = !organization.scimConfiguration || hasIdentityProvider; + + // Show Manual SCIM section when: + // - Manual SCIM is configured (no bridge) + // - Or no SCIM config at all (user can choose to enable manual) + const showManualScimSection = hasManualScim || !organization.scimConfiguration; + + // Show provisioning events when SCIM is configured (either manual or via IdP) + const showProvisioningEvents = !!organization.scimConfiguration; + return (
-
-

{__("SCIM Provisioning")}

- -
+ {showIdentityProviderSection && ( + + )} - {organization.scimConfiguration && ( + {showManualScimSection && (
-

{__("SCIM Event History")}

+

{__("Manual SCIM")}

+ {!hasManualScim && ( +

+ {__( + "Configure SCIM manually if your identity provider is not listed above. This requires setting up the SCIM endpoint URL and bearer token in your identity provider.", + )} +

+ )} + +
+ )} + + {showProvisioningEvents && ( +
+

+ {__("Provisioning Event History")} +

)} diff --git a/apps/console/src/pages/iam/organizations/settings/_components/ConnectorList.tsx b/apps/console/src/pages/iam/organizations/settings/_components/ConnectorList.tsx new file mode 100644 index 000000000..4742c284d --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/_components/ConnectorList.tsx @@ -0,0 +1,32 @@ +import { useTranslate } from "@probo/i18n"; +import { graphql, useFragment } from "react-relay"; + +import type { ConnectorListFragment$key } from "#/__generated__/iam/ConnectorListFragment.graphql"; + +import { GoogleWorkspaceConnector } from "./GoogleWorkspaceConnector"; + +const connectorListFragment = graphql` + fragment ConnectorListFragment on Organization { + scimConfiguration { + ...GoogleWorkspaceConnectorFragment + } + } +`; + +export function ConnectorList(props: { fKey: ConnectorListFragment$key }) { + const { fKey } = props; + const data = useFragment(connectorListFragment, fKey); + const { __ } = useTranslate(); + + return ( +
+

{__("Identity Provider")}

+

+ {__( + "Connect your identity provider to automatically sync users to your organization. Once connected, you don't need to configure SCIM manually.", + )} +

+ +
+ ); +} diff --git a/apps/console/src/pages/iam/organizations/settings/_components/GoogleWorkspaceConnector.tsx b/apps/console/src/pages/iam/organizations/settings/_components/GoogleWorkspaceConnector.tsx new file mode 100644 index 000000000..bdef63f58 --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/_components/GoogleWorkspaceConnector.tsx @@ -0,0 +1,171 @@ +import { sprintf } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Badge, + Button, + Card, + Dialog, + DialogContent, + DialogFooter, + useDialogRef, +} from "@probo/ui"; +import { graphql, useFragment } from "react-relay"; + +import type { GoogleWorkspaceConnectorDeleteMutation } from "#/__generated__/iam/GoogleWorkspaceConnectorDeleteMutation.graphql"; +import type { GoogleWorkspaceConnectorFragment$key } from "#/__generated__/iam/GoogleWorkspaceConnectorFragment.graphql"; +import { useMutationWithToasts } from "#/hooks/useMutationWithToasts"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +const googleWorkspaceConnectorFragment = graphql` + fragment GoogleWorkspaceConnectorFragment on SCIMConfiguration { + id + bridge { + connector { + id + createdAt + } + } + } +`; + +const deleteSCIMConfigurationMutation = graphql` + mutation GoogleWorkspaceConnectorDeleteMutation( + $input: DeleteSCIMConfigurationInput! + ) { + deleteSCIMConfiguration(input: $input) { + deletedScimConfigurationId + } + } +`; + +export function GoogleWorkspaceConnector(props: { + fKey: GoogleWorkspaceConnectorFragment$key | null; +}) { + const { fKey } = props; + const data = useFragment(googleWorkspaceConnectorFragment, fKey); + const connector = data?.bridge?.connector; + const scimConfigurationId = data?.id; + + const organizationId = useOrganizationId(); + const { __, dateTimeFormat } = useTranslate(); + const dialogRef = useDialogRef(); + + const [deleteSCIMConfiguration, isDeleting] + = useMutationWithToasts( + deleteSCIMConfigurationMutation, + { + successMessage: __("Google Workspace disconnected successfully"), + errorMessage: __("Failed to disconnect Google Workspace"), + }, + ); + + const handleConnect = () => { + const baseUrl = import.meta.env.VITE_API_URL || window.location.origin; + const url = new URL("/api/console/v1/connectors/initiate", baseUrl); + url.searchParams.append("organization_id", organizationId); + url.searchParams.append("provider", "GOOGLE_WORKSPACE"); + const continueUrl = `/organizations/${organizationId}/settings/scim`; + url.searchParams.append("continue", continueUrl); + window.location.href = url.toString(); + }; + + const handleDisconnect = () => { + if (!connector || !scimConfigurationId) return; + + void deleteSCIMConfiguration({ + variables: { + input: { + organizationId: organizationId, + scimConfigurationId: scimConfigurationId, + }, + }, + onCompleted: () => { + dialogRef.current?.close(); + }, + updater: (store) => { + const organizationRecord = store.get(organizationId); + if (organizationRecord) { + organizationRecord.setValue(null, "scimConfiguration"); + } + }, + }); + }; + + // Not connected state + if (!connector) { + return ( + +
+ Google Workspace +
+
+

{__("Google Workspace")}

+

+ {__( + "Connect Google Workspace to automatically sync users via SCIM.", + )} +

+
+ +
+ ); + } + + // Connected state + return ( + +
+ Google Workspace +
+
+

{__("Google Workspace")}

+

+ {sprintf(__("Connected on %s"), dateTimeFormat(connector.createdAt))} +

+
+ + {__("Connected")} + + + {__("Disconnect")} + + )} + title={__("Disconnect Google Workspace")} + className="max-w-lg" + > + +

+ {__( + "This will disconnect your Google Workspace integration. Users will no longer be automatically synced via SCIM.", + )} +

+

+ {__("This action cannot be undone.")} +

+
+ + + +
+
+ ); +} diff --git a/apps/console/src/pages/iam/organizations/settings/_components/SCIMConfiguration.tsx b/apps/console/src/pages/iam/organizations/settings/_components/SCIMConfiguration.tsx index 694ef4c80..34fabffd1 100644 --- a/apps/console/src/pages/iam/organizations/settings/_components/SCIMConfiguration.tsx +++ b/apps/console/src/pages/iam/organizations/settings/_components/SCIMConfiguration.tsx @@ -20,9 +20,20 @@ import type { SCIMConfigurationRegenerateTokenMutation } from "#/__generated__/i import { useOrganizationId } from "#/hooks/useOrganizationId"; const SCIMConfigurationFragment = graphql` - fragment SCIMConfigurationFragment on SCIMConfiguration { - id - endpointUrl + fragment SCIMConfigurationFragment on Organization { + canCreateSCIMConfiguration: permission( + action: "iam:scim-configuration:create" + ) + canDeleteSCIMConfiguration: permission( + action: "iam:scim-configuration:delete" + ) + scimConfiguration { + id + endpointUrl + bridge { + id + } + } } `; @@ -75,15 +86,19 @@ const regenerateSCIMTokenMutation = graphql` `; export function SCIMConfiguration(props: { - fKey: SCIMConfigurationFragment$key | null; - canCreate: boolean; - canDelete: boolean; + fKey: SCIMConfigurationFragment$key; }) { - const { canCreate, canDelete, fKey } = props; + const { fKey } = props; const organizationId = useOrganizationId(); - const scimConfiguration = useFragment(SCIMConfigurationFragment, fKey); + const organization = useFragment(SCIMConfigurationFragment, fKey); + const { + canCreateSCIMConfiguration: canCreate, + canDeleteSCIMConfiguration: canDelete, + scimConfiguration, + } = organization; + const hasIdentityProvider = !!scimConfiguration?.bridge; const { __ } = useTranslate(); const { toast } = useToast(); @@ -117,7 +132,7 @@ export function SCIMConfiguration(props: { variant: "error", title: __("Error"), description: formatError( - __("SCIM configuration creation failed"), + __("Manual SCIM configuration failed"), e, ), }); @@ -128,7 +143,7 @@ export function SCIMConfiguration(props: { setToken(response.createSCIMConfiguration.token); } toast({ - title: __("SCIM Configuration Created"), + title: __("Manual SCIM Configured"), description: __( "Copy the bearer token now. It will not be shown again.", ), @@ -159,7 +174,7 @@ export function SCIMConfiguration(props: { deleteDialogRef.current?.close(); setToken(null); toast({ - title: __("SCIM Configuration Deleted"), + title: __("Manual SCIM Configuration Deleted"), description: __( "All SCIM-provisioned memberships have been changed to manual source.", ), @@ -218,14 +233,18 @@ export function SCIMConfiguration(props: { }; if (!scimConfiguration) { + if (hasIdentityProvider) { + return null; + } + return (
-

{__("SCIM is not configured")}

+

{__("Manual SCIM is not configured")}

{__( - "Enable SCIM to automatically provision users from your identity provider.", + "Generate a SCIM endpoint and bearer token to configure your identity provider manually.", )}

@@ -250,10 +269,10 @@ export function SCIMConfiguration(props: {
-

{__("SCIM Provisioning Active")}

+

{__("Manual SCIM Active")}

{__( - "Automatic user provisioning is enabled for this organization.", + "Use these credentials to configure SCIM in your identity provider.", )}

@@ -328,17 +347,17 @@ export function SCIMConfiguration(props: { deleteDialogRef.current?.close()} >

{__( - "Are you sure you want to delete the SCIM configuration? This will:", + "Are you sure you want to delete the manual SCIM configuration? This will:", )}

    -
  • {__("Disable automatic user provisioning")}
  • +
  • {__("Disable manual SCIM provisioning")}
  • {__("Change all SCIM-provisioned memberships to manual source")}
  • diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index 4b8e9751d..3c99337e6 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -8,7 +8,7 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [ react({ - exclude: ["src/pages/iam/**/*"], + exclude: ["src/pages/iam/**/*", "src/components/connectors/**/*"], babel: { plugins: [ [ @@ -22,7 +22,7 @@ export default defineConfig({ }, }), react({ - include: ["src/pages/iam/**/*"], + include: ["src/pages/iam/**/*", "src/components/connectors/**/*"], babel: { plugins: [ [ diff --git a/cfg/dev.yaml b/cfg/dev.yaml index ad87ab16b..fee8446ec 100644 --- a/cfg/dev.yaml +++ b/cfg/dev.yaml @@ -107,7 +107,7 @@ probod: key-type: "EC256" connectors: - - provider: "slack" + - provider: "SLACK" protocol: "oauth2" config: client-id: "slack-client-id" @@ -121,3 +121,18 @@ probod: - "incoming-webhook" settings: signing-secret: "this-is-not-a-secret-for-slack-signing" + - provider: "GOOGLE_WORKSPACE" + protocol: "oauth2" + config: + client-id: "google-workspace-client-id" + client-secret: "thisisnotasecret" + redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete" + auth-url: "https://accounts.google.com/o/oauth2/v2/auth" + token-url: "https://oauth2.googleapis.com/token" + scopes: + - "https://www.googleapis.com/auth/admin.directory.user.readonly" + - "https://www.googleapis.com/auth/admin.directory.userschema.readonly" + - "https://www.googleapis.com/auth/admin.directory.group.member.readonly" + extra-auth-params: + access_type: "offline" + prompt: "consent" diff --git a/go.mod b/go.mod index d90cfe388..7c77e5bc2 100644 --- a/go.mod +++ b/go.mod @@ -35,11 +35,16 @@ require ( go.opentelemetry.io/otel/trace v1.39.0 go.probo.inc/mcpgen v0.0.0-20251124210642-41a5174eb92f golang.org/x/crypto v0.47.0 - golang.org/x/image v0.35.0 + golang.org/x/image v0.33.0 + golang.org/x/oauth2 v0.34.0 golang.org/x/sync v0.19.0 + google.golang.org/api v0.260.0 ) require ( + cloud.google.com/go/auth v0.18.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect @@ -64,6 +69,7 @@ require ( github.com/di-wu/xsd-datetime v1.0.0 // indirect github.com/dnephin/pflag v1.0.7 // indirect github.com/fatih/color v1.18.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -75,8 +81,11 @@ require ( github.com/goccy/go-yaml v1.19.2 // indirect github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.9 // indirect + github.com/googleapis/gax-go/v2 v2.16.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect @@ -118,6 +127,7 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.gearno.de/x/panicf v0.1.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect @@ -126,7 +136,6 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.49.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.33.0 // indirect diff --git a/go.sum b/go.sum index 9bd843669..5c19e050e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= +cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= codeberg.org/miekg/dns v0.6.40 h1:dzO+f0pcQEejc5kQYhOV8pSdJ14uc640M21valACl4s= codeberg.org/miekg/dns v0.6.40/go.mod h1:fIxAzBMDPnXWSw0fp8+pfZMRiAqYY4+HHYLzUo/S6Dg= github.com/99designs/gqlgen v0.17.86 h1:C8N3UTa5heXX6twl+b0AJyGkTwYL6dNmFrgZNLRcU6w= @@ -77,6 +83,8 @@ github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 h1:0+BTyxIYgiVAry github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8/go.mod h1:JkjcmqbLW+khwt2fmBPJFBhx2zGZ8XobRZ+O0VhlwWo= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= @@ -114,10 +122,16 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.9 h1:TOpi/QG8iDcZlkQlGlFUti/ZtyLkliXvHDcyUIMuFrU= +github.com/googleapis/enterprise-certificate-proxy v0.3.9/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= +github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= @@ -257,6 +271,8 @@ go.gearno.de/x/ref v0.0.0-20240502200927-d74926fcb14c h1:b8Wwr2owaB6g38tptSk5tek go.gearno.de/x/ref v0.0.0-20240502200927-d74926fcb14c/go.mod h1:k3GtgnI5X9dl8FlqaNYkCkil7/iACQdFOromU/H4u6I= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= @@ -283,8 +299,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I= -golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk= +golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= +golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= @@ -304,6 +320,10 @@ golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.260.0 h1:XbNi5E6bOVEj/uLXQRlt6TKuEzMD7zvW/6tNwltE4P4= +google.golang.org/api v0.260.0/go.mod h1:Shj1j0Phr/9sloYrKomICzdYgsSDImpTxME8rGLaZ/o= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index c0aa7908f..712a1f14e 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -28,7 +28,7 @@ type ( Connector interface { Initiate(ctx context.Context, provider string, organizationID gid.GID, r *http.Request) (string, error) - Complete(ctx context.Context, r *http.Request) (Connection, *gid.GID, error) + Complete(ctx context.Context, r *http.Request) (Connection, *gid.GID, string, error) // returns: connection, organizationID, continueURL, error } Connection interface { diff --git a/pkg/connector/oauth2.go b/pkg/connector/oauth2.go index 3342f1a72..2104d9c39 100644 --- a/pkg/connector/oauth2.go +++ b/pkg/connector/oauth2.go @@ -38,17 +38,19 @@ import ( type ( OAuth2Connector struct { - ClientID string - ClientSecret string - RedirectURI string - Scopes []string - AuthURL string - TokenURL string + ClientID string + ClientSecret string + RedirectURI string + Scopes []string + AuthURL string + TokenURL string + ExtraAuthParams map[string]string // Optional: extra params for auth URL (e.g., access_type=offline for Google) } OAuth2State struct { OrganizationID string `json:"oid"` Provider string `json:"provider"` + ContinueURL string `json:"continue,omitempty"` } OAuth2Connection struct { @@ -73,21 +75,30 @@ func (c *OAuth2Connector) Initiate(ctx context.Context, provider string, organiz OrganizationID: organizationID.String(), Provider: provider, } + if r != nil { + if continueURL := r.URL.Query().Get("continue"); continueURL != "" { + stateData.ContinueURL = continueURL + } + } + return c.InitiateWithState(ctx, stateData, r) +} + +// InitiateWithState generates an OAuth2 authorization URL with a custom state. +// This allows callers to include additional context (like SCIMBridgeID) in the state. +func (c *OAuth2Connector) InitiateWithState(ctx context.Context, stateData OAuth2State, r *http.Request) (string, error) { state, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL, stateData) if err != nil { return "", fmt.Errorf("cannot create state token: %w", err) } + // Build redirect URI with provider (fixed per provider, so can be registered in OAuth console) redirectURI := c.RedirectURI redirectURIParsed, err := url.Parse(redirectURI) if err != nil { return "", fmt.Errorf("cannot parse redirect URI: %w", err) } q := redirectURIParsed.Query() - q.Set("provider", provider) - if continueURL := r.URL.Query().Get("continue"); continueURL != "" { - q.Set("continue", continueURL) - } + q.Set("provider", stateData.Provider) redirectURIParsed.RawQuery = q.Encode() redirectURI = redirectURIParsed.String() @@ -98,6 +109,11 @@ func (c *OAuth2Connector) Initiate(ctx context.Context, provider string, organiz authCodeQuery.Set("response_type", "code") authCodeQuery.Set("scope", strings.Join(c.Scopes, " ")) + // Add any extra auth params (e.g., access_type=offline, prompt=consent for Google) + for k, v := range c.ExtraAuthParams { + authCodeQuery.Set(k, v) + } + u, err := url.Parse(c.AuthURL) if err != nil { return "", fmt.Errorf("cannot parse auth URL: %w", err) @@ -108,7 +124,23 @@ func (c *OAuth2Connector) Initiate(ctx context.Context, provider string, organiz return u.String(), nil } -func (c *OAuth2Connector) Complete(ctx context.Context, r *http.Request) (Connection, *gid.GID, error) { +func (c *OAuth2Connector) Complete(ctx context.Context, r *http.Request) (Connection, *gid.GID, string, error) { + conn, state, err := c.CompleteWithState(ctx, r) + if err != nil { + return nil, nil, "", err + } + + organizationID, err := gid.ParseGID(state.OrganizationID) + if err != nil { + return nil, nil, "", fmt.Errorf("cannot parse organization ID: %w", err) + } + + return conn, &organizationID, state.ContinueURL, nil +} + +// CompleteWithState completes the OAuth2 flow and returns the full state. +// This allows callers to access additional context (like SCIMBridgeID) from the state. +func (c *OAuth2Connector) CompleteWithState(ctx context.Context, r *http.Request) (Connection, *OAuth2State, error) { provider := r.URL.Query().Get("provider") if provider == "" { return nil, nil, fmt.Errorf("missing provider in query parameters") @@ -138,6 +170,7 @@ func (c *OAuth2Connector) Complete(ctx context.Context, r *http.Request) (Connec return nil, nil, fmt.Errorf("cannot parse organization ID: %w", err) } + // Build redirect URI with provider (must match what was sent to auth endpoint) redirectURI := c.RedirectURI redirectURIParsed, err := url.Parse(redirectURI) if err != nil { @@ -145,9 +178,6 @@ func (c *OAuth2Connector) Complete(ctx context.Context, r *http.Request) (Connec } q := redirectURIParsed.Query() q.Set("provider", provider) - if continueURL := r.URL.Query().Get("continue"); continueURL != "" { - q.Set("continue", continueURL) - } redirectURIParsed.RawQuery = q.Encode() redirectURI = redirectURIParsed.String() @@ -191,10 +221,11 @@ func (c *OAuth2Connector) Complete(ctx context.Context, r *http.Request) (Connec } if provider == SlackProvider { - return ParseSlackTokenResponse(body, oauth2Conn, organizationID) + conn, _, err := ParseSlackTokenResponse(body, oauth2Conn, organizationID) + return conn, &payload.Data, err } - return &oauth2Conn, &organizationID, nil + return &oauth2Conn, &payload.Data, nil } func (c *OAuth2Connection) Type() ProtocolType { diff --git a/pkg/connector/registry.go b/pkg/connector/registry.go index f9ddd6ab4..5b2a37958 100644 --- a/pkg/connector/registry.go +++ b/pkg/connector/registry.go @@ -65,10 +65,10 @@ func (cr *ConnectorRegistry) Initiate(ctx context.Context, provider string, orga return connector.Initiate(ctx, provider, organizationID, r) } -func (cr *ConnectorRegistry) Complete(ctx context.Context, provider string, r *http.Request) (Connection, *gid.GID, error) { +func (cr *ConnectorRegistry) Complete(ctx context.Context, provider string, r *http.Request) (Connection, *gid.GID, string, error) { connector, err := cr.Get(provider) if err != nil { - return nil, nil, fmt.Errorf("cannot complete connector: %w", err) + return nil, nil, "", fmt.Errorf("cannot complete connector: %w", err) } return connector.Complete(ctx, r) diff --git a/pkg/coredata/connector.go b/pkg/coredata/connector.go index 3a7784deb..dc4ac461d 100644 --- a/pkg/coredata/connector.go +++ b/pkg/coredata/connector.go @@ -150,6 +150,110 @@ func (c *Connectors) LoadAllByOrganizationIDWithoutDecryptedConnection( return c.loadAllByOrganizationID(ctx, conn, scope, organizationID) } +func (c *Connector) LoadByID( + ctx context.Context, + conn pg.Conn, + scope Scoper, + connectorID gid.GID, + encryptionKey cipher.EncryptionKey, +) error { + if err := c.LoadMetadataByID(ctx, conn, scope, connectorID); err != nil { + return err + } + + // Decrypt the connection + if len(c.EncryptedConnection) > 0 { + decryptedConnection, err := cipher.Decrypt(c.EncryptedConnection, encryptionKey) + if err != nil { + return fmt.Errorf("cannot decrypt connection: %w", err) + } + + c.Connection, err = connector.UnmarshalConnection(c.Protocol.String(), c.Provider.String(), decryptedConnection) + if err != nil { + return fmt.Errorf("cannot unmarshal connection: %w", err) + } + + c.populateSlackSettings() + } + + return nil +} + +// LoadMetadataByID loads connector metadata without decrypting the connection. +// Use this when you only need provider, organization, or other metadata. +func (c *Connector) LoadMetadataByID( + ctx context.Context, + conn pg.Conn, + scope Scoper, + connectorID gid.GID, +) error { + q := ` +SELECT + id, + organization_id, + provider, + protocol, + settings, + encrypted_connection, + created_at, + updated_at +FROM + connectors +WHERE + %s + AND id = @id +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"id": connectorID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query connectors: %w", err) + } + + loadedConnector, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Connector]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + return fmt.Errorf("cannot collect connector row: %w", err) + } + + *c = loadedConnector + + return nil +} + +func (c *Connector) Delete( + ctx context.Context, + conn pg.Conn, + scope Scoper, +) error { + q := ` +DELETE FROM connectors +WHERE %s AND id = @id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"id": c.ID} + maps.Copy(args, scope.SQLArguments()) + + result, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot delete connector: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + return nil +} + func (c *Connector) Insert( ctx context.Context, conn pg.Conn, diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index 897b0c4f6..bc5708595 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -22,12 +22,14 @@ import ( type ConnectorProvider string const ( - ConnectorProviderSlack ConnectorProvider = "SLACK" + ConnectorProviderSlack ConnectorProvider = "SLACK" + ConnectorProviderGoogleWorkspace ConnectorProvider = "GOOGLE_WORKSPACE" ) func ConnectorProviders() []ConnectorProvider { return []ConnectorProvider{ ConnectorProviderSlack, + ConnectorProviderGoogleWorkspace, } } @@ -49,6 +51,8 @@ func (cp *ConnectorProvider) Scan(value any) error { switch s { case "SLACK": *cp = ConnectorProviderSlack + case "GOOGLE_WORKSPACE": + *cp = ConnectorProviderGoogleWorkspace default: return fmt.Errorf("invalid ConnectorProvider value: %q", s) } diff --git a/pkg/coredata/entity_type_reg.go b/pkg/coredata/entity_type_reg.go index e99c31549..93262b342 100644 --- a/pkg/coredata/entity_type_reg.go +++ b/pkg/coredata/entity_type_reg.go @@ -78,6 +78,7 @@ const ( SCIMConfigurationEntityType uint16 = 52 SCIMEventEntityType uint16 = 53 TokenEntityType uint16 = 54 + SCIMBridgeEntityType uint16 = 55 ) func NewEntityFromID(id gid.GID) (any, bool) { @@ -190,6 +191,8 @@ func NewEntityFromID(id gid.GID) (any, bool) { return &SCIMEvent{ID: id}, true case TokenEntityType: return &Token{ID: id}, true + case SCIMBridgeEntityType: + return &SCIMBridge{ID: id}, true default: return nil, false } diff --git a/pkg/coredata/migrations/20260127T120000Z.sql b/pkg/coredata/migrations/20260127T120000Z.sql new file mode 100644 index 000000000..dcf1d54de --- /dev/null +++ b/pkg/coredata/migrations/20260127T120000Z.sql @@ -0,0 +1,12 @@ +CREATE TABLE iam_scim_bridges ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + scim_configuration_id TEXT NOT NULL REFERENCES iam_scim_configurations(id) ON DELETE CASCADE, + connector_id TEXT REFERENCES connectors(id) ON DELETE SET NULL, + type TEXT NOT NULL, + state TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT iam_scim_bridges_scim_configuration_unique UNIQUE (scim_configuration_id) +); diff --git a/pkg/coredata/migrations/20260129T041655Z.sql b/pkg/coredata/migrations/20260129T041655Z.sql new file mode 100644 index 000000000..a52010c45 --- /dev/null +++ b/pkg/coredata/migrations/20260129T041655Z.sql @@ -0,0 +1 @@ +ALTER TYPE connector_provider ADD VALUE 'GOOGLE_WORKSPACE'; diff --git a/pkg/coredata/scim_bridge.go b/pkg/coredata/scim_bridge.go new file mode 100644 index 000000000..6db56d3e5 --- /dev/null +++ b/pkg/coredata/scim_bridge.go @@ -0,0 +1,323 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + SCIMBridge struct { + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + ScimConfigurationID gid.GID `db:"scim_configuration_id"` + ConnectorID *gid.GID `db:"connector_id"` + Type SCIMBridgeType `db:"type"` + State SCIMBridgeState `db:"state"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + SCIMBridges []*SCIMBridge +) + +func (s *SCIMBridge) CursorKey(orderBy SCIMBridgeOrderField) page.CursorKey { + switch orderBy { + case SCIMBridgeOrderFieldCreatedAt: + return page.NewCursorKey(s.ID, s.CreatedAt) + } + + panic(fmt.Sprintf("unsupported order by: %s", orderBy)) +} + +func (s *SCIMBridge) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) { + q := `SELECT organization_id FROM iam_scim_bridges WHERE id = $1 LIMIT 1;` + + var organizationID gid.GID + if err := conn.QueryRow(ctx, q, s.ID).Scan(&organizationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrResourceNotFound + } + return nil, fmt.Errorf("cannot query scim bridge authorization attributes: %w", err) + } + + return map[string]string{"organization_id": organizationID.String()}, nil +} + +func (s *SCIMBridge) LoadByID( + ctx context.Context, + conn pg.Conn, + scope Scoper, + bridgeID gid.GID, +) error { + q := ` +SELECT + id, + organization_id, + scim_configuration_id, + connector_id, + type, + state, + created_at, + updated_at +FROM + iam_scim_bridges +WHERE + %s + AND id = @id +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"id": bridgeID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query iam_scim_bridges: %w", err) + } + + bridge, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[SCIMBridge]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect scim_bridge: %w", err) + } + + *s = bridge + + return nil +} + +func (s *SCIMBridge) LoadByOrganizationID( + ctx context.Context, + conn pg.Conn, + scope Scoper, + organizationID gid.GID, +) error { + q := ` +SELECT + id, + organization_id, + scim_configuration_id, + connector_id, + type, + state, + created_at, + updated_at +FROM + iam_scim_bridges +WHERE + %s + AND organization_id = @organization_id +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"organization_id": organizationID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query iam_scim_bridges: %w", err) + } + + bridge, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[SCIMBridge]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect scim_bridge: %w", err) + } + + *s = bridge + + return nil +} + +func (s *SCIMBridge) LoadBySCIMConfigurationID( + ctx context.Context, + conn pg.Conn, + scope Scoper, + scimConfigurationID gid.GID, +) error { + q := ` +SELECT + id, + organization_id, + scim_configuration_id, + connector_id, + type, + state, + created_at, + updated_at +FROM + iam_scim_bridges +WHERE + %s + AND scim_configuration_id = @scim_configuration_id +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"scim_configuration_id": scimConfigurationID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query iam_scim_bridges: %w", err) + } + + bridge, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[SCIMBridge]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect scim_bridge: %w", err) + } + + *s = bridge + + return nil +} + +func (s *SCIMBridge) Insert( + ctx context.Context, + conn pg.Conn, + scope Scoper, +) error { + q := ` +INSERT INTO iam_scim_bridges ( + id, + tenant_id, + organization_id, + scim_configuration_id, + connector_id, + type, + state, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @organization_id, + @scim_configuration_id, + @connector_id, + @type, + @state, + @created_at, + @updated_at +) +` + + args := pgx.StrictNamedArgs{ + "id": s.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": s.OrganizationID, + "scim_configuration_id": s.ScimConfigurationID, + "connector_id": s.ConnectorID, + "type": s.Type, + "state": s.State, + "created_at": s.CreatedAt, + "updated_at": s.UpdatedAt, + } + + _, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot insert scim_bridge: %w", err) + } + + return nil +} + +func (s *SCIMBridge) Update( + ctx context.Context, + conn pg.Conn, + scope Scoper, +) error { + q := ` +UPDATE iam_scim_bridges +SET + connector_id = @connector_id, + state = @state, + updated_at = @updated_at +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "id": s.ID, + "connector_id": s.ConnectorID, + "state": s.State, + "updated_at": s.UpdatedAt, + } + + maps.Copy(args, scope.SQLArguments()) + + _, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update scim_bridge: %w", err) + } + + return nil +} + +func (s *SCIMBridge) Delete( + ctx context.Context, + conn pg.Conn, + scope Scoper, +) error { + q := ` +DELETE FROM iam_scim_bridges +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"id": s.ID} + maps.Copy(args, scope.SQLArguments()) + + result, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot delete scim_bridge: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + return nil +} diff --git a/pkg/coredata/scim_bridge_order_field.go b/pkg/coredata/scim_bridge_order_field.go new file mode 100644 index 000000000..56f9b60ac --- /dev/null +++ b/pkg/coredata/scim_bridge_order_field.go @@ -0,0 +1,40 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +type ( + SCIMBridgeOrderField string +) + +const ( + SCIMBridgeOrderFieldCreatedAt SCIMBridgeOrderField = "CREATED_AT" +) + +func (p SCIMBridgeOrderField) Column() string { + return string(p) +} + +func (p SCIMBridgeOrderField) String() string { + return string(p) +} + +func (p SCIMBridgeOrderField) MarshalText() ([]byte, error) { + return []byte(p.String()), nil +} + +func (p *SCIMBridgeOrderField) UnmarshalText(text []byte) error { + *p = SCIMBridgeOrderField(text) + return nil +} diff --git a/pkg/coredata/scim_bridge_state.go b/pkg/coredata/scim_bridge_state.go new file mode 100644 index 000000000..c61aa2e1f --- /dev/null +++ b/pkg/coredata/scim_bridge_state.go @@ -0,0 +1,60 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import ( + "database/sql/driver" + "fmt" +) + +type SCIMBridgeState string + +const ( + SCIMBridgeStatePending SCIMBridgeState = "PENDING" + SCIMBridgeStateActive SCIMBridgeState = "ACTIVE" + SCIMBridgeStateFailed SCIMBridgeState = "FAILED" +) + +func (s SCIMBridgeState) String() string { + return string(s) +} + +func (s *SCIMBridgeState) Scan(value any) error { + var str string + switch v := value.(type) { + case string: + str = v + case []byte: + str = string(v) + default: + return fmt.Errorf("unsupported type for SCIMBridgeState: %T", value) + } + + switch str { + case "PENDING": + *s = SCIMBridgeStatePending + case "ACTIVE": + *s = SCIMBridgeStateActive + case "FAILED": + *s = SCIMBridgeStateFailed + default: + return fmt.Errorf("invalid SCIMBridgeState value: %q", str) + } + return nil +} + +func (s SCIMBridgeState) Value() (driver.Value, error) { + return s.String(), nil +} diff --git a/pkg/coredata/scim_bridge_type.go b/pkg/coredata/scim_bridge_type.go new file mode 100644 index 000000000..ef35d8535 --- /dev/null +++ b/pkg/coredata/scim_bridge_type.go @@ -0,0 +1,54 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import ( + "database/sql/driver" + "fmt" +) + +type SCIMBridgeType string + +const ( + SCIMBridgeTypeGoogleWorkspace SCIMBridgeType = "GOOGLE_WORKSPACE" +) + +func (t SCIMBridgeType) String() string { + return string(t) +} + +func (t *SCIMBridgeType) Scan(value any) error { + var str string + switch v := value.(type) { + case string: + str = v + case []byte: + str = string(v) + default: + return fmt.Errorf("unsupported type for SCIMBridgeType: %T", value) + } + + switch str { + case "GOOGLE_WORKSPACE": + *t = SCIMBridgeTypeGoogleWorkspace + default: + return fmt.Errorf("invalid SCIMBridgeType value: %q", str) + } + return nil +} + +func (t SCIMBridgeType) Value() (driver.Value, error) { + return t.String(), nil +} diff --git a/pkg/coredata/scim_configuration.go b/pkg/coredata/scim_configuration.go index 2aa7688a7..7945aa70c 100644 --- a/pkg/coredata/scim_configuration.go +++ b/pkg/coredata/scim_configuration.go @@ -32,6 +32,7 @@ type ( SCIMConfiguration struct { ID gid.GID `db:"id"` OrganizationID gid.GID `db:"organization_id"` + BridgeID *gid.GID `db:"bridge_id"` HashedToken []byte `db:"hashed_token"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` @@ -70,18 +71,31 @@ func (s *SCIMConfiguration) LoadByID( configID gid.GID, ) error { q := ` +WITH scim_config AS ( + SELECT + id, + organization_id, + hashed_token, + created_at, + updated_at + FROM + iam_scim_configurations + WHERE + %s + AND id = @id + LIMIT 1 +) SELECT - id, - organization_id, - hashed_token, - created_at, - updated_at + sc.id, + sc.organization_id, + b.id AS bridge_id, + sc.hashed_token, + sc.created_at, + sc.updated_at FROM - iam_scim_configurations -WHERE - %s - AND id = @id -LIMIT 1; + scim_config sc +LEFT JOIN + iam_scim_bridges b ON b.scim_configuration_id = sc.id; ` q = fmt.Sprintf(q, scope.SQLFragment()) @@ -115,18 +129,31 @@ func (s *SCIMConfiguration) LoadByOrganizationID( organizationID gid.GID, ) error { q := ` +WITH scim_config AS ( + SELECT + id, + organization_id, + hashed_token, + created_at, + updated_at + FROM + iam_scim_configurations + WHERE + %s + AND organization_id = @organization_id + LIMIT 1 +) SELECT - id, - organization_id, - hashed_token, - created_at, - updated_at + sc.id, + sc.organization_id, + b.id AS bridge_id, + sc.hashed_token, + sc.created_at, + sc.updated_at FROM - iam_scim_configurations -WHERE - %s - AND organization_id = @organization_id -LIMIT 1; + scim_config sc +LEFT JOIN + iam_scim_bridges b ON b.scim_configuration_id = sc.id; ` q = fmt.Sprintf(q, scope.SQLFragment()) @@ -159,17 +186,30 @@ func (s *SCIMConfiguration) LoadByHashedToken( hashedToken []byte, ) error { q := ` +WITH scim_config AS ( + SELECT + id, + organization_id, + hashed_token, + created_at, + updated_at + FROM + iam_scim_configurations + WHERE + hashed_token = @hashed_token + LIMIT 1 +) SELECT - id, - organization_id, - hashed_token, - created_at, - updated_at + sc.id, + sc.organization_id, + b.id AS bridge_id, + sc.hashed_token, + sc.created_at, + sc.updated_at FROM - iam_scim_configurations -WHERE - hashed_token = @hashed_token -LIMIT 1; + scim_config sc +LEFT JOIN + iam_scim_bridges b ON b.scim_configuration_id = sc.id; ` args := pgx.StrictNamedArgs{"hashed_token": hashedToken} diff --git a/pkg/iam/errors.go b/pkg/iam/errors.go index 06f7b1ba1..9c0d606ba 100644 --- a/pkg/iam/errors.go +++ b/pkg/iam/errors.go @@ -357,3 +357,23 @@ func NewNoSCIMConfigurationFoundError(organizationID gid.GID) error { func (e ErrNoSCIMConfigurationFound) Error() string { return fmt.Sprintf("SCIM configuration not found for organization %q", e.OrganizationID) } + +type ErrSCIMBridgeNotFound struct{ BridgeID gid.GID } + +func NewSCIMBridgeNotFoundError(bridgeID gid.GID) error { + return &ErrSCIMBridgeNotFound{BridgeID: bridgeID} +} + +func (e ErrSCIMBridgeNotFound) Error() string { + return fmt.Sprintf("SCIM bridge %q not found", e.BridgeID) +} + +type ErrConnectorNotFound struct{ ConnectorID gid.GID } + +func NewConnectorNotFoundError(connectorID gid.GID) error { + return &ErrConnectorNotFound{ConnectorID: connectorID} +} + +func (e ErrConnectorNotFound) Error() string { + return fmt.Sprintf("connector %q not found", e.ConnectorID) +} diff --git a/pkg/iam/iam_actions.go b/pkg/iam/iam_actions.go index 3bd292567..a614a32ba 100644 --- a/pkg/iam/iam_actions.go +++ b/pkg/iam/iam_actions.go @@ -77,4 +77,12 @@ const ( // SCIM Event actions ActionSCIMEventList = "iam:scim-event:list" ActionSCIMEventGet = "iam:scim-event:get" + + // SCIM Bridge actions + ActionSCIMBridgeGet = "iam:scim-bridge:get" + ActionSCIMBridgeCreate = "iam:scim-bridge:create" + ActionSCIMBridgeDelete = "iam:scim-bridge:delete" + + // Connector actions + ActionConnectorGet = "iam:connector:get" ) diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index 3cbb40549..d73285497 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -1296,6 +1296,30 @@ func (s OrganizationService) DeleteSCIMConfiguration( return fmt.Errorf("cannot reset membership sources: %w", err) } + // Delete SCIM bridge and its connector if they exist + bridge := &coredata.SCIMBridge{} + err = bridge.LoadBySCIMConfigurationID(ctx, tx, scope, configID) + if err != nil && err != coredata.ErrResourceNotFound { + return fmt.Errorf("cannot load SCIM bridge: %w", err) + } + + if err == nil { + // Bridge exists, delete connector if it has one + if bridge.ConnectorID != nil { + connector := &coredata.Connector{ID: *bridge.ConnectorID} + err = connector.Delete(ctx, tx, scope) + if err != nil && err != coredata.ErrResourceNotFound { + return fmt.Errorf("cannot delete connector: %w", err) + } + } + + // Delete the bridge + err = bridge.Delete(ctx, tx, scope) + if err != nil { + return fmt.Errorf("cannot delete SCIM bridge: %w", err) + } + } + err = config.Delete(ctx, tx, scope) if err != nil { return fmt.Errorf("cannot delete SCIM configuration: %w", err) @@ -1591,3 +1615,293 @@ func (s OrganizationService) GetOrganization(ctx context.Context, organizationID return organization, nil } + +func (s OrganizationService) GetSCIMBridgeByID(ctx context.Context, bridgeID gid.GID) (*coredata.SCIMBridge, error) { + var ( + scope = coredata.NewScopeFromObjectID(bridgeID) + bridge = &coredata.SCIMBridge{} + ) + + err := s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + err := bridge.LoadByID(ctx, conn, scope, bridgeID) + if err != nil { + if err == coredata.ErrResourceNotFound { + return NewSCIMBridgeNotFoundError(bridgeID) + } + + return fmt.Errorf("cannot load SCIM bridge: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, err + } + + return bridge, nil +} + +func (s OrganizationService) GetConnectorByID(ctx context.Context, connectorID gid.GID) (*coredata.Connector, error) { + var ( + scope = coredata.NewScopeFromObjectID(connectorID) + connector = &coredata.Connector{} + ) + + err := s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + err := connector.LoadByID(ctx, conn, scope, connectorID, s.encryptionKey) + if err != nil { + if err == coredata.ErrResourceNotFound { + return NewConnectorNotFoundError(connectorID) + } + + return fmt.Errorf("cannot load connector: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, err + } + + return connector, nil +} + +// GetConnectorMetadataByID returns connector metadata without decrypting the connection. +// Use this when you only need provider, organization, or other metadata fields. +func (s OrganizationService) GetConnectorMetadataByID(ctx context.Context, connectorID gid.GID) (*coredata.Connector, error) { + var ( + scope = coredata.NewScopeFromObjectID(connectorID) + connector = &coredata.Connector{} + ) + + err := s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + err := connector.LoadMetadataByID(ctx, conn, scope, connectorID) + if err != nil { + if err == coredata.ErrResourceNotFound { + return NewConnectorNotFoundError(connectorID) + } + + return fmt.Errorf("cannot load connector: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, err + } + + return connector, nil +} + +func (s OrganizationService) GetSCIMBridgeByOrganizationID(ctx context.Context, organizationID gid.GID) (*coredata.SCIMBridge, error) { + var ( + scope = coredata.NewScopeFromObjectID(organizationID) + bridge = &coredata.SCIMBridge{} + ) + + err := s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + err := bridge.LoadByOrganizationID(ctx, conn, scope, organizationID) + if err != nil { + if err == coredata.ErrResourceNotFound { + return nil // No bridge found, not an error + } + + return fmt.Errorf("cannot load SCIM bridge: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, err + } + + // If bridge ID is empty, no bridge was found + if bridge.ID == (gid.GID{}) { + return nil, nil + } + + return bridge, nil +} + +func (s OrganizationService) LinkConnectorToSCIMBridge( + ctx context.Context, + bridgeID gid.GID, + connectorID gid.GID, +) (*coredata.SCIMBridge, error) { + var ( + scope = coredata.NewScopeFromObjectID(bridgeID) + bridge = &coredata.SCIMBridge{} + ) + + err := s.pg.WithTx( + ctx, + func(tx pg.Conn) error { + err := bridge.LoadByID(ctx, tx, scope, bridgeID) + if err != nil { + if err == coredata.ErrResourceNotFound { + return NewSCIMBridgeNotFoundError(bridgeID) + } + return fmt.Errorf("cannot load SCIM bridge: %w", err) + } + + // Update the bridge with the connector ID + bridge.ConnectorID = &connectorID + bridge.State = coredata.SCIMBridgeStateActive + bridge.UpdatedAt = time.Now() + + if err := bridge.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update SCIM bridge: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, err + } + + return bridge, nil +} + +func (s OrganizationService) CreateSCIMBridge( + ctx context.Context, + organizationID gid.GID, + scimConfigurationID gid.GID, + connectorID gid.GID, +) (*coredata.SCIMBridge, error) { + var ( + scope = coredata.NewScopeFromObjectID(organizationID) + now = time.Now() + bridge *coredata.SCIMBridge + ) + + err := s.pg.WithTx( + ctx, + func(tx pg.Conn) error { + organization := &coredata.Organization{} + err := organization.LoadByID(ctx, tx, scope, organizationID) + if err != nil { + if err == coredata.ErrResourceNotFound { + return NewOrganizationNotFoundError(organizationID) + } + return fmt.Errorf("cannot load organization: %w", err) + } + + config := &coredata.SCIMConfiguration{} + err = config.LoadByID(ctx, tx, scope, scimConfigurationID) + if err != nil { + if err == coredata.ErrResourceNotFound { + return scim.NewSCIMConfigurationNotFoundError(scimConfigurationID) + } + return fmt.Errorf("cannot load SCIM configuration: %w", err) + } + + if config.OrganizationID != organizationID { + return scim.NewSCIMConfigurationNotFoundError(scimConfigurationID) + } + + // Load and validate the connector (metadata only, no decryption needed) + existingConnector := &coredata.Connector{} + err = existingConnector.LoadMetadataByID(ctx, tx, scope, connectorID) + if err != nil { + if err == coredata.ErrResourceNotFound { + return NewConnectorNotFoundError(connectorID) + } + + return fmt.Errorf("cannot load connector: %w", err) + } + + // Verify connector belongs to the same organization + if existingConnector.OrganizationID != organizationID { + return NewConnectorNotFoundError(connectorID) + } + + // Map connector provider to bridge type + var bridgeType coredata.SCIMBridgeType + switch existingConnector.Provider { + case coredata.ConnectorProviderGoogleWorkspace: + bridgeType = coredata.SCIMBridgeTypeGoogleWorkspace + default: + return fmt.Errorf("connector provider %s is not supported for SCIM bridge", existingConnector.Provider) + } + + bridge = &coredata.SCIMBridge{ + ID: gid.New(organizationID.TenantID(), coredata.SCIMBridgeEntityType), + OrganizationID: organizationID, + ScimConfigurationID: scimConfigurationID, + ConnectorID: &connectorID, + Type: bridgeType, + State: coredata.SCIMBridgeStateActive, // Active immediately since connector already exists + CreatedAt: now, + UpdatedAt: now, + } + + if err := bridge.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert SCIM bridge: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, err + } + + return bridge, nil +} + +func (s OrganizationService) DeleteSCIMBridge(ctx context.Context, organizationID gid.GID, bridgeID gid.GID) error { + var ( + scope = coredata.NewScopeFromObjectID(organizationID) + bridge = &coredata.SCIMBridge{} + ) + + err := s.pg.WithTx( + ctx, + func(tx pg.Conn) error { + organization := &coredata.Organization{} + err := organization.LoadByID(ctx, tx, scope, organizationID) + if err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + if err := bridge.LoadByID(ctx, tx, scope, bridgeID); err != nil { + return fmt.Errorf("cannot load SCIM bridge: %w", err) + } + + if bridge.OrganizationID != organizationID { + return NewSCIMBridgeNotFoundError(bridgeID) + } + + if err := bridge.Delete(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot delete SCIM bridge: %w", err) + } + + return nil + }, + ) + + if err != nil { + return err + } + + return nil +} diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index 0495e2353..f68387ea5 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -278,6 +278,10 @@ const ( // SlackConnection actions ActionSlackConnectionList = "core:slack-connection:list" + // Connector actions (generic) + ActionConnectorList = "core:connector:list" + ActionConnectorDelete = "core:connector:delete" + // DataProtectionImpactAssessment actions ActionDataProtectionImpactAssessmentList = "core:data-protection-impact-assessment:list" ActionDataProtectionImpactAssessmentGet = "core:data-protection-impact-assessment:get" diff --git a/pkg/probo/connector_service.go b/pkg/probo/connector_service.go index 84ab436f1..ef9210826 100644 --- a/pkg/probo/connector_service.go +++ b/pkg/probo/connector_service.go @@ -94,6 +94,52 @@ func (s *ConnectorService) ListForOrganizationID( return page.NewPage(connectors, cursor), nil } +func (s *ConnectorService) GetByOrganizationIDAndProvider( + ctx context.Context, + organizationID gid.GID, + provider coredata.ConnectorProvider, +) (*coredata.Connector, error) { + var connectors coredata.Connectors + + err := s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) error { + return connectors.LoadAllByOrganizationIDProtocolAndProvider( + ctx, + conn, + s.svc.scope, + organizationID, + coredata.ConnectorProtocolOAuth2, + provider, + s.svc.encryptionKey, + ) + }, + ) + + if err != nil { + return nil, fmt.Errorf("cannot get connector: %w", err) + } + + if len(connectors) == 0 { + return nil, coredata.ErrResourceNotFound + } + + return connectors[0], nil +} + +func (s *ConnectorService) Delete( + ctx context.Context, + connectorID gid.GID, +) error { + return s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) error { + cnnctr := &coredata.Connector{ID: connectorID} + return cnnctr.Delete(ctx, conn, s.svc.scope) + }, + ) +} + func (s *ConnectorService) Create( ctx context.Context, req CreateConnectorRequest, diff --git a/pkg/probo/policies.go b/pkg/probo/policies.go index d60103d11..792406069 100644 --- a/pkg/probo/policies.go +++ b/pkg/probo/policies.go @@ -76,7 +76,7 @@ var ViewerPolicy = policy.NewPolicy( ActionSnapshotGet, ActionSnapshotList, ActionMeetingGet, ActionMeetingList, ActionFileGet, ActionFileDownloadUrl, - ActionSlackConnectionList, + ActionSlackConnectionList, ActionConnectorList, ActionRightsRequestGet, ActionRightsRequestList, ActionStateOfApplicabilityGet, ActionStateOfApplicabilityList, ActionApplicabilityStatementGet, ActionApplicabilityStatementList, diff --git a/pkg/probod/connector_config.go b/pkg/probod/connector_config.go index 230d33be4..dadc4020a 100644 --- a/pkg/probod/connector_config.go +++ b/pkg/probod/connector_config.go @@ -32,12 +32,13 @@ type ( } connectorConfigOAuth2 struct { - ClientID string `json:"client-id"` - ClientSecret string `json:"client-secret"` - RedirectURI string `json:"redirect-uri"` - AuthURL string `json:"auth-url"` - TokenURL string `json:"token-url"` - Scopes []string `json:"scopes"` + ClientID string `json:"client-id"` + ClientSecret string `json:"client-secret"` + RedirectURI string `json:"redirect-uri"` + AuthURL string `json:"auth-url"` + TokenURL string `json:"token-url"` + Scopes []string `json:"scopes"` + ExtraAuthParams map[string]string `json:"extra-auth-params,omitempty"` } ) @@ -85,12 +86,13 @@ func (c *connectorConfig) UnmarshalJSON(data []byte) error { } oauth2Connector := connector.OAuth2Connector{ - ClientID: config.ClientID, - ClientSecret: config.ClientSecret, - RedirectURI: config.RedirectURI, - AuthURL: config.AuthURL, - TokenURL: config.TokenURL, - Scopes: config.Scopes, + ClientID: config.ClientID, + ClientSecret: config.ClientSecret, + RedirectURI: config.RedirectURI, + AuthURL: config.AuthURL, + TokenURL: config.TokenURL, + Scopes: config.Scopes, + ExtraAuthParams: config.ExtraAuthParams, } c.Config = &oauth2Connector diff --git a/pkg/server/api/connect/v1/schema.graphql b/pkg/server/api/connect/v1/schema.graphql index aa12d3425..7e59f6db5 100644 --- a/pkg/server/api/connect/v1/schema.graphql +++ b/pkg/server/api/connect/v1/schema.graphql @@ -347,6 +347,8 @@ type SCIMConfiguration implements Node { updatedAt: Datetime! organization: Organization @goField(forceResolver: true) + bridge: SCIMBridge @goField(forceResolver: true) + events( first: Int after: CursorKey @@ -360,6 +362,50 @@ type SCIMConfiguration implements Node { @session(required: PRESENT) } +type SCIMBridge implements Node { + id: ID! + state: SCIMBridgeState! + scimConfiguration: SCIMConfiguration @goField(forceResolver: true) + connector: Connector @goField(forceResolver: true) + type: SCIMBridgeType! + createdAt: Datetime! + updatedAt: Datetime! + + permission(action: String!): Boolean! + @goField(forceResolver: true) + @session(required: PRESENT) +} + +type Connector implements Node { + id: ID! + provider: ConnectorProvider! + createdAt: Datetime! + updatedAt: Datetime! + + permission(action: String!): Boolean! + @goField(forceResolver: true) + @session(required: PRESENT) +} + +enum ConnectorProvider + @goModel(model: "go.probo.inc/probo/pkg/coredata.ConnectorProvider") { + SLACK @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSlack") + GOOGLE_WORKSPACE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderGoogleWorkspace") +} + +enum SCIMBridgeType + @goModel(model: "go.probo.inc/probo/pkg/coredata.SCIMBridgeType") { + GOOGLE_WORKSPACE @goEnum(value: "go.probo.inc/probo/pkg/coredata.SCIMBridgeTypeGoogleWorkspace") +} + +enum SCIMBridgeState + @goModel(model: "go.probo.inc/probo/pkg/coredata.SCIMBridgeState") { + PENDING @goEnum(value: "go.probo.inc/probo/pkg/coredata.SCIMBridgeStatePending") + ACTIVE @goEnum(value: "go.probo.inc/probo/pkg/coredata.SCIMBridgeStateActive") + FAILED @goEnum(value: "go.probo.inc/probo/pkg/coredata.SCIMBridgeStateFailed") +} + type SCIMEvent implements Node { id: ID! method: String! @@ -846,6 +892,7 @@ type DeleteSAMLConfigurationPayload { input CreateSCIMConfigurationInput { organizationId: ID! + connectorId: ID } input DeleteSCIMConfigurationInput { @@ -860,6 +907,7 @@ input RegenerateSCIMTokenInput { type CreateSCIMConfigurationPayload { scimConfiguration: SCIMConfiguration! + scimBridge: SCIMBridge token: String! } diff --git a/pkg/server/api/connect/v1/schema/schema.go b/pkg/server/api/connect/v1/schema/schema.go index 18749a9a1..9359b8045 100644 --- a/pkg/server/api/connect/v1/schema/schema.go +++ b/pkg/server/api/connect/v1/schema/schema.go @@ -47,6 +47,7 @@ type Config struct { } type ResolverRoot interface { + Connector() ConnectorResolver Identity() IdentityResolver Invitation() InvitationResolver InvitationConnection() InvitationConnectionResolver @@ -60,6 +61,7 @@ type ResolverRoot interface { Query() QueryResolver SAMLConfiguration() SAMLConfigurationResolver SAMLConfigurationConnection() SAMLConfigurationConnectionResolver + SCIMBridge() SCIMBridgeResolver SCIMConfiguration() SCIMConfigurationResolver SCIMEvent() SCIMEventResolver SCIMEventConnection() SCIMEventConnectionResolver @@ -89,6 +91,14 @@ type ComplexityRoot struct { Success func(childComplexity int) int } + Connector struct { + CreatedAt func(childComplexity int) int + ID func(childComplexity int) int + Permission func(childComplexity int, action string) int + Provider func(childComplexity int) int + UpdatedAt func(childComplexity int) int + } + CreateOrganizationPayload struct { MembershipEdge func(childComplexity int) int Organization func(childComplexity int) int @@ -104,6 +114,7 @@ type ComplexityRoot struct { } CreateSCIMConfigurationPayload struct { + ScimBridge func(childComplexity int) int ScimConfiguration func(childComplexity int) int Token func(childComplexity int) int } @@ -365,7 +376,19 @@ type ComplexityRoot struct { Node func(childComplexity int) int } + SCIMBridge struct { + Connector func(childComplexity int) int + CreatedAt func(childComplexity int) int + ID func(childComplexity int) int + Permission func(childComplexity int, action string) int + ScimConfiguration func(childComplexity int) int + State func(childComplexity int) int + Type func(childComplexity int) int + UpdatedAt func(childComplexity int) int + } + SCIMConfiguration struct { + Bridge func(childComplexity int) int CreatedAt func(childComplexity int) int EndpointURL func(childComplexity int) int Events func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.SCIMEventOrderBy) int @@ -456,6 +479,9 @@ type ComplexityRoot struct { } } +type ConnectorResolver interface { + Permission(ctx context.Context, obj *types.Connector, action string) (bool, error) +} type IdentityResolver interface { Memberships(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.MembershipOrderBy) (*types.MembershipConnection, error) PendingInvitations(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.InvitationOrderBy) (*types.InvitationConnection, error) @@ -546,10 +572,17 @@ type SAMLConfigurationResolver interface { type SAMLConfigurationConnectionResolver interface { TotalCount(ctx context.Context, obj *types.SAMLConfigurationConnection) (*int, error) } +type SCIMBridgeResolver interface { + ScimConfiguration(ctx context.Context, obj *types.SCIMBridge) (*types.SCIMConfiguration, error) + Connector(ctx context.Context, obj *types.SCIMBridge) (*types.Connector, error) + + Permission(ctx context.Context, obj *types.SCIMBridge, action string) (bool, error) +} type SCIMConfigurationResolver interface { EndpointURL(ctx context.Context, obj *types.SCIMConfiguration) (string, error) Organization(ctx context.Context, obj *types.SCIMConfiguration) (*types.Organization, error) + Bridge(ctx context.Context, obj *types.SCIMConfiguration) (*types.SCIMBridge, error) Events(ctx context.Context, obj *types.SCIMConfiguration, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.SCIMEventOrderBy) (*types.SCIMEventConnection, error) Permission(ctx context.Context, obj *types.SCIMConfiguration, action string) (bool, error) } @@ -623,6 +656,42 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.ChangePasswordPayload.Success(childComplexity), true + case "Connector.createdAt": + if e.complexity.Connector.CreatedAt == nil { + break + } + + return e.complexity.Connector.CreatedAt(childComplexity), true + case "Connector.id": + if e.complexity.Connector.ID == nil { + break + } + + return e.complexity.Connector.ID(childComplexity), true + case "Connector.permission": + if e.complexity.Connector.Permission == nil { + break + } + + args, err := ec.field_Connector_permission_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Connector.Permission(childComplexity, args["action"].(string)), true + case "Connector.provider": + if e.complexity.Connector.Provider == nil { + break + } + + return e.complexity.Connector.Provider(childComplexity), true + case "Connector.updatedAt": + if e.complexity.Connector.UpdatedAt == nil { + break + } + + return e.complexity.Connector.UpdatedAt(childComplexity), true + case "CreateOrganizationPayload.membershipEdge": if e.complexity.CreateOrganizationPayload.MembershipEdge == nil { break @@ -656,6 +725,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.CreateSAMLConfigurationPayload.SamlConfigurationEdge(childComplexity), true + case "CreateSCIMConfigurationPayload.scimBridge": + if e.complexity.CreateSCIMConfigurationPayload.ScimBridge == nil { + break + } + + return e.complexity.CreateSCIMConfigurationPayload.ScimBridge(childComplexity), true case "CreateSCIMConfigurationPayload.scimConfiguration": if e.complexity.CreateSCIMConfigurationPayload.ScimConfiguration == nil { break @@ -1831,6 +1906,66 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.SAMLConfigurationEdge.Node(childComplexity), true + case "SCIMBridge.connector": + if e.complexity.SCIMBridge.Connector == nil { + break + } + + return e.complexity.SCIMBridge.Connector(childComplexity), true + case "SCIMBridge.createdAt": + if e.complexity.SCIMBridge.CreatedAt == nil { + break + } + + return e.complexity.SCIMBridge.CreatedAt(childComplexity), true + case "SCIMBridge.id": + if e.complexity.SCIMBridge.ID == nil { + break + } + + return e.complexity.SCIMBridge.ID(childComplexity), true + case "SCIMBridge.permission": + if e.complexity.SCIMBridge.Permission == nil { + break + } + + args, err := ec.field_SCIMBridge_permission_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.SCIMBridge.Permission(childComplexity, args["action"].(string)), true + case "SCIMBridge.scimConfiguration": + if e.complexity.SCIMBridge.ScimConfiguration == nil { + break + } + + return e.complexity.SCIMBridge.ScimConfiguration(childComplexity), true + case "SCIMBridge.state": + if e.complexity.SCIMBridge.State == nil { + break + } + + return e.complexity.SCIMBridge.State(childComplexity), true + case "SCIMBridge.type": + if e.complexity.SCIMBridge.Type == nil { + break + } + + return e.complexity.SCIMBridge.Type(childComplexity), true + case "SCIMBridge.updatedAt": + if e.complexity.SCIMBridge.UpdatedAt == nil { + break + } + + return e.complexity.SCIMBridge.UpdatedAt(childComplexity), true + + case "SCIMConfiguration.bridge": + if e.complexity.SCIMConfiguration.Bridge == nil { + break + } + + return e.complexity.SCIMConfiguration.Bridge(childComplexity), true case "SCIMConfiguration.createdAt": if e.complexity.SCIMConfiguration.CreatedAt == nil { break @@ -2622,6 +2757,8 @@ type SCIMConfiguration implements Node { updatedAt: Datetime! organization: Organization @goField(forceResolver: true) + bridge: SCIMBridge @goField(forceResolver: true) + events( first: Int after: CursorKey @@ -2635,6 +2772,50 @@ type SCIMConfiguration implements Node { @session(required: PRESENT) } +type SCIMBridge implements Node { + id: ID! + state: SCIMBridgeState! + scimConfiguration: SCIMConfiguration @goField(forceResolver: true) + connector: Connector @goField(forceResolver: true) + type: SCIMBridgeType! + createdAt: Datetime! + updatedAt: Datetime! + + permission(action: String!): Boolean! + @goField(forceResolver: true) + @session(required: PRESENT) +} + +type Connector implements Node { + id: ID! + provider: ConnectorProvider! + createdAt: Datetime! + updatedAt: Datetime! + + permission(action: String!): Boolean! + @goField(forceResolver: true) + @session(required: PRESENT) +} + +enum ConnectorProvider + @goModel(model: "go.probo.inc/probo/pkg/coredata.ConnectorProvider") { + SLACK @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSlack") + GOOGLE_WORKSPACE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderGoogleWorkspace") +} + +enum SCIMBridgeType + @goModel(model: "go.probo.inc/probo/pkg/coredata.SCIMBridgeType") { + GOOGLE_WORKSPACE @goEnum(value: "go.probo.inc/probo/pkg/coredata.SCIMBridgeTypeGoogleWorkspace") +} + +enum SCIMBridgeState + @goModel(model: "go.probo.inc/probo/pkg/coredata.SCIMBridgeState") { + PENDING @goEnum(value: "go.probo.inc/probo/pkg/coredata.SCIMBridgeStatePending") + ACTIVE @goEnum(value: "go.probo.inc/probo/pkg/coredata.SCIMBridgeStateActive") + FAILED @goEnum(value: "go.probo.inc/probo/pkg/coredata.SCIMBridgeStateFailed") +} + type SCIMEvent implements Node { id: ID! method: String! @@ -3121,6 +3302,7 @@ type DeleteSAMLConfigurationPayload { input CreateSCIMConfigurationInput { organizationId: ID! + connectorId: ID } input DeleteSCIMConfigurationInput { @@ -3135,6 +3317,7 @@ input RegenerateSCIMTokenInput { type CreateSCIMConfigurationPayload { scimConfiguration: SCIMConfiguration! + scimBridge: SCIMBridge token: String! } @@ -3204,6 +3387,17 @@ func (ec *executionContext) dir_session_args(ctx context.Context, rawArgs map[st return args, nil } +func (ec *executionContext) field_Connector_permission_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "action", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["action"] = arg0 + return args, nil +} + func (ec *executionContext) field_Identity_memberships_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -3823,6 +4017,17 @@ func (ec *executionContext) field_SAMLConfiguration_permission_args(ctx context. return args, nil } +func (ec *executionContext) field_SCIMBridge_permission_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "action", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["action"] = arg0 + return args, nil +} + func (ec *executionContext) field_SCIMConfiguration_events_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -4112,6 +4317,181 @@ func (ec *executionContext) fieldContext_ChangePasswordPayload_success(_ context return fc, nil } +func (ec *executionContext) _Connector_id(ctx context.Context, field graphql.CollectedField, obj *types.Connector) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Connector_id, + func(ctx context.Context) (any, error) { + return obj.ID, nil + }, + nil, + ec.marshalNID2goᚗproboᚗincᚋproboᚋpkgᚋgidᚐGID, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Connector_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Connector", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ID does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Connector_provider(ctx context.Context, field graphql.CollectedField, obj *types.Connector) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Connector_provider, + func(ctx context.Context) (any, error) { + return obj.Provider, nil + }, + nil, + ec.marshalNConnectorProvider2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐConnectorProvider, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Connector_provider(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Connector", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ConnectorProvider does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Connector_createdAt(ctx context.Context, field graphql.CollectedField, obj *types.Connector) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Connector_createdAt, + func(ctx context.Context) (any, error) { + return obj.CreatedAt, nil + }, + nil, + ec.marshalNDatetime2timeᚐTime, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Connector_createdAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Connector", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Datetime does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Connector_updatedAt(ctx context.Context, field graphql.CollectedField, obj *types.Connector) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Connector_updatedAt, + func(ctx context.Context) (any, error) { + return obj.UpdatedAt, nil + }, + nil, + ec.marshalNDatetime2timeᚐTime, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Connector_updatedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Connector", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Datetime does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Connector_permission(ctx context.Context, field graphql.CollectedField, obj *types.Connector) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Connector_permission, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.resolvers.Connector().Permission(ctx, obj, fc.Args["action"].(string)) + }, + func(ctx context.Context, next graphql.Resolver) graphql.Resolver { + directive0 := next + + directive1 := func(ctx context.Context) (any, error) { + required, err := ec.unmarshalNSessionRequirement2goᚗproboᚗincᚋproboᚋpkgᚋserverᚋgqlutilsᚋdirectivesᚋsessionᚐSessionRequirement(ctx, "PRESENT") + if err != nil { + var zeroVal bool + return zeroVal, err + } + if ec.directives.Session == nil { + var zeroVal bool + return zeroVal, errors.New("directive session is not implemented") + } + return ec.directives.Session(ctx, obj, directive0, required) + } + + next = directive1 + return next + }, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Connector_permission(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Connector", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Connector_permission_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _CreateOrganizationPayload_organization(ctx context.Context, field graphql.CollectedField, obj *types.CreateOrganizationPayload) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -4343,6 +4723,8 @@ func (ec *executionContext) fieldContext_CreateSCIMConfigurationPayload_scimConf return ec.fieldContext_SCIMConfiguration_updatedAt(ctx, field) case "organization": return ec.fieldContext_SCIMConfiguration_organization(ctx, field) + case "bridge": + return ec.fieldContext_SCIMConfiguration_bridge(ctx, field) case "events": return ec.fieldContext_SCIMConfiguration_events(ctx, field) case "permission": @@ -4354,6 +4736,53 @@ func (ec *executionContext) fieldContext_CreateSCIMConfigurationPayload_scimConf return fc, nil } +func (ec *executionContext) _CreateSCIMConfigurationPayload_scimBridge(ctx context.Context, field graphql.CollectedField, obj *types.CreateSCIMConfigurationPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CreateSCIMConfigurationPayload_scimBridge, + func(ctx context.Context) (any, error) { + return obj.ScimBridge, nil + }, + nil, + ec.marshalOSCIMBridge2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐSCIMBridge, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_CreateSCIMConfigurationPayload_scimBridge(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CreateSCIMConfigurationPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_SCIMBridge_id(ctx, field) + case "state": + return ec.fieldContext_SCIMBridge_state(ctx, field) + case "scimConfiguration": + return ec.fieldContext_SCIMBridge_scimConfiguration(ctx, field) + case "connector": + return ec.fieldContext_SCIMBridge_connector(ctx, field) + case "type": + return ec.fieldContext_SCIMBridge_type(ctx, field) + case "createdAt": + return ec.fieldContext_SCIMBridge_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_SCIMBridge_updatedAt(ctx, field) + case "permission": + return ec.fieldContext_SCIMBridge_permission(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SCIMBridge", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _CreateSCIMConfigurationPayload_token(ctx context.Context, field graphql.CollectedField, obj *types.CreateSCIMConfigurationPayload) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -8007,6 +8436,8 @@ func (ec *executionContext) fieldContext_Mutation_createSCIMConfiguration(ctx co switch field.Name { case "scimConfiguration": return ec.fieldContext_CreateSCIMConfigurationPayload_scimConfiguration(ctx, field) + case "scimBridge": + return ec.fieldContext_CreateSCIMConfigurationPayload_scimBridge(ctx, field) case "token": return ec.fieldContext_CreateSCIMConfigurationPayload_token(ctx, field) } @@ -8626,6 +9057,8 @@ func (ec *executionContext) fieldContext_Organization_scimConfiguration(_ contex return ec.fieldContext_SCIMConfiguration_updatedAt(ctx, field) case "organization": return ec.fieldContext_SCIMConfiguration_organization(ctx, field) + case "bridge": + return ec.fieldContext_SCIMConfiguration_bridge(ctx, field) case "events": return ec.fieldContext_SCIMConfiguration_events(ctx, field) case "permission": @@ -9731,6 +10164,8 @@ func (ec *executionContext) fieldContext_RegenerateSCIMTokenPayload_scimConfigur return ec.fieldContext_SCIMConfiguration_updatedAt(ctx, field) case "organization": return ec.fieldContext_SCIMConfiguration_organization(ctx, field) + case "bridge": + return ec.fieldContext_SCIMConfiguration_bridge(ctx, field) case "events": return ec.fieldContext_SCIMConfiguration_events(ctx, field) case "permission": @@ -10727,6 +11162,298 @@ func (ec *executionContext) fieldContext_SAMLConfigurationEdge_cursor(_ context. return fc, nil } +func (ec *executionContext) _SCIMBridge_id(ctx context.Context, field graphql.CollectedField, obj *types.SCIMBridge) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SCIMBridge_id, + func(ctx context.Context) (any, error) { + return obj.ID, nil + }, + nil, + ec.marshalNID2goᚗproboᚗincᚋproboᚋpkgᚋgidᚐGID, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SCIMBridge_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SCIMBridge", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ID does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SCIMBridge_state(ctx context.Context, field graphql.CollectedField, obj *types.SCIMBridge) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SCIMBridge_state, + func(ctx context.Context) (any, error) { + return obj.State, nil + }, + nil, + ec.marshalNSCIMBridgeState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐSCIMBridgeState, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SCIMBridge_state(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SCIMBridge", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type SCIMBridgeState does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SCIMBridge_scimConfiguration(ctx context.Context, field graphql.CollectedField, obj *types.SCIMBridge) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SCIMBridge_scimConfiguration, + func(ctx context.Context) (any, error) { + return ec.resolvers.SCIMBridge().ScimConfiguration(ctx, obj) + }, + nil, + ec.marshalOSCIMConfiguration2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐSCIMConfiguration, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_SCIMBridge_scimConfiguration(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SCIMBridge", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_SCIMConfiguration_id(ctx, field) + case "endpointUrl": + return ec.fieldContext_SCIMConfiguration_endpointUrl(ctx, field) + case "createdAt": + return ec.fieldContext_SCIMConfiguration_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_SCIMConfiguration_updatedAt(ctx, field) + case "organization": + return ec.fieldContext_SCIMConfiguration_organization(ctx, field) + case "bridge": + return ec.fieldContext_SCIMConfiguration_bridge(ctx, field) + case "events": + return ec.fieldContext_SCIMConfiguration_events(ctx, field) + case "permission": + return ec.fieldContext_SCIMConfiguration_permission(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SCIMConfiguration", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _SCIMBridge_connector(ctx context.Context, field graphql.CollectedField, obj *types.SCIMBridge) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SCIMBridge_connector, + func(ctx context.Context) (any, error) { + return ec.resolvers.SCIMBridge().Connector(ctx, obj) + }, + nil, + ec.marshalOConnector2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐConnector, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_SCIMBridge_connector(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SCIMBridge", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Connector_id(ctx, field) + case "provider": + return ec.fieldContext_Connector_provider(ctx, field) + case "createdAt": + return ec.fieldContext_Connector_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_Connector_updatedAt(ctx, field) + case "permission": + return ec.fieldContext_Connector_permission(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Connector", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _SCIMBridge_type(ctx context.Context, field graphql.CollectedField, obj *types.SCIMBridge) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SCIMBridge_type, + func(ctx context.Context) (any, error) { + return obj.Type, nil + }, + nil, + ec.marshalNSCIMBridgeType2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐSCIMBridgeType, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SCIMBridge_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SCIMBridge", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type SCIMBridgeType does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SCIMBridge_createdAt(ctx context.Context, field graphql.CollectedField, obj *types.SCIMBridge) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SCIMBridge_createdAt, + func(ctx context.Context) (any, error) { + return obj.CreatedAt, nil + }, + nil, + ec.marshalNDatetime2timeᚐTime, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SCIMBridge_createdAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SCIMBridge", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Datetime does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SCIMBridge_updatedAt(ctx context.Context, field graphql.CollectedField, obj *types.SCIMBridge) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SCIMBridge_updatedAt, + func(ctx context.Context) (any, error) { + return obj.UpdatedAt, nil + }, + nil, + ec.marshalNDatetime2timeᚐTime, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SCIMBridge_updatedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SCIMBridge", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Datetime does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SCIMBridge_permission(ctx context.Context, field graphql.CollectedField, obj *types.SCIMBridge) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SCIMBridge_permission, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.resolvers.SCIMBridge().Permission(ctx, obj, fc.Args["action"].(string)) + }, + func(ctx context.Context, next graphql.Resolver) graphql.Resolver { + directive0 := next + + directive1 := func(ctx context.Context) (any, error) { + required, err := ec.unmarshalNSessionRequirement2goᚗproboᚗincᚋproboᚋpkgᚋserverᚋgqlutilsᚋdirectivesᚋsessionᚐSessionRequirement(ctx, "PRESENT") + if err != nil { + var zeroVal bool + return zeroVal, err + } + if ec.directives.Session == nil { + var zeroVal bool + return zeroVal, errors.New("directive session is not implemented") + } + return ec.directives.Session(ctx, obj, directive0, required) + } + + next = directive1 + return next + }, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SCIMBridge_permission(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SCIMBridge", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_SCIMBridge_permission_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _SCIMConfiguration_id(ctx context.Context, field graphql.CollectedField, obj *types.SCIMConfiguration) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -10906,6 +11633,53 @@ func (ec *executionContext) fieldContext_SCIMConfiguration_organization(_ contex return fc, nil } +func (ec *executionContext) _SCIMConfiguration_bridge(ctx context.Context, field graphql.CollectedField, obj *types.SCIMConfiguration) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SCIMConfiguration_bridge, + func(ctx context.Context) (any, error) { + return ec.resolvers.SCIMConfiguration().Bridge(ctx, obj) + }, + nil, + ec.marshalOSCIMBridge2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐSCIMBridge, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_SCIMConfiguration_bridge(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SCIMConfiguration", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_SCIMBridge_id(ctx, field) + case "state": + return ec.fieldContext_SCIMBridge_state(ctx, field) + case "scimConfiguration": + return ec.fieldContext_SCIMBridge_scimConfiguration(ctx, field) + case "connector": + return ec.fieldContext_SCIMBridge_connector(ctx, field) + case "type": + return ec.fieldContext_SCIMBridge_type(ctx, field) + case "createdAt": + return ec.fieldContext_SCIMBridge_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_SCIMBridge_updatedAt(ctx, field) + case "permission": + return ec.fieldContext_SCIMBridge_permission(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SCIMBridge", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _SCIMConfiguration_events(ctx context.Context, field graphql.CollectedField, obj *types.SCIMConfiguration) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -14191,7 +14965,7 @@ func (ec *executionContext) unmarshalInputCreateSCIMConfigurationInput(ctx conte asMap[k] = v } - fieldsInOrder := [...]string{"organizationId"} + fieldsInOrder := [...]string{"organizationId", "connectorId"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -14205,6 +14979,13 @@ func (ec *executionContext) unmarshalInputCreateSCIMConfigurationInput(ctx conte return it, err } it.OrganizationID = data + case "connectorId": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("connectorId")) + data, err := ec.unmarshalOID2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋgidᚐGID(ctx, v) + if err != nil { + return it, err + } + it.ConnectorID = data } } @@ -15177,6 +15958,13 @@ func (ec *executionContext) _Node(ctx context.Context, sel ast.SelectionSet, obj return graphql.Null } return ec._SCIMConfiguration(ctx, sel, obj) + case types.SCIMBridge: + return ec._SCIMBridge(ctx, sel, &obj) + case *types.SCIMBridge: + if obj == nil { + return graphql.Null + } + return ec._SCIMBridge(ctx, sel, obj) case types.SAMLConfiguration: return ec._SAMLConfiguration(ctx, sel, &obj) case *types.SAMLConfiguration: @@ -15226,6 +16014,13 @@ func (ec *executionContext) _Node(ctx context.Context, sel ast.SelectionSet, obj return graphql.Null } return ec._Identity(ctx, sel, obj) + case types.Connector: + return ec._Connector(ctx, sel, &obj) + case *types.Connector: + if obj == nil { + return graphql.Null + } + return ec._Connector(ctx, sel, obj) default: panic(fmt.Errorf("unexpected type %T", obj)) } @@ -15396,6 +16191,96 @@ func (ec *executionContext) _ChangePasswordPayload(ctx context.Context, sel ast. return out } +var connectorImplementors = []string{"Connector", "Node"} + +func (ec *executionContext) _Connector(ctx context.Context, sel ast.SelectionSet, obj *types.Connector) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, connectorImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Connector") + case "id": + out.Values[i] = ec._Connector_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "provider": + out.Values[i] = ec._Connector_provider(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "createdAt": + out.Values[i] = ec._Connector_createdAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "updatedAt": + out.Values[i] = ec._Connector_updatedAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "permission": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Connector_permission(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var createOrganizationPayloadImplementors = []string{"CreateOrganizationPayload"} func (ec *executionContext) _CreateOrganizationPayload(ctx context.Context, sel ast.SelectionSet, obj *types.CreateOrganizationPayload) graphql.Marshaler { @@ -15536,6 +16421,8 @@ func (ec *executionContext) _CreateSCIMConfigurationPayload(ctx context.Context, if out.Values[i] == graphql.Null { out.Invalids++ } + case "scimBridge": + out.Values[i] = ec._CreateSCIMConfigurationPayload_scimBridge(ctx, field, obj) case "token": out.Values[i] = ec._CreateSCIMConfigurationPayload_token(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -18363,6 +19250,167 @@ func (ec *executionContext) _SAMLConfigurationEdge(ctx context.Context, sel ast. return out } +var sCIMBridgeImplementors = []string{"SCIMBridge", "Node"} + +func (ec *executionContext) _SCIMBridge(ctx context.Context, sel ast.SelectionSet, obj *types.SCIMBridge) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, sCIMBridgeImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("SCIMBridge") + case "id": + out.Values[i] = ec._SCIMBridge_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "state": + out.Values[i] = ec._SCIMBridge_state(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "scimConfiguration": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._SCIMBridge_scimConfiguration(ctx, field, obj) + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "connector": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._SCIMBridge_connector(ctx, field, obj) + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "type": + out.Values[i] = ec._SCIMBridge_type(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "createdAt": + out.Values[i] = ec._SCIMBridge_createdAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "updatedAt": + out.Values[i] = ec._SCIMBridge_updatedAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "permission": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._SCIMBridge_permission(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var sCIMConfigurationImplementors = []string{"SCIMConfiguration", "Node"} func (ec *executionContext) _SCIMConfiguration(ctx context.Context, sel ast.SelectionSet, obj *types.SCIMConfiguration) graphql.Marshaler { @@ -18457,6 +19505,39 @@ func (ec *executionContext) _SCIMConfiguration(ctx context.Context, sel ast.Sele continue } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "bridge": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._SCIMConfiguration_bridge(ctx, field, obj) + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "events": field := field @@ -19744,6 +20825,34 @@ func (ec *executionContext) unmarshalNChangePasswordInput2goᚗproboᚗincᚋpro return res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalNConnectorProvider2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐConnectorProvider(ctx context.Context, v any) (coredata.ConnectorProvider, error) { + tmp, err := graphql.UnmarshalString(v) + res := unmarshalNConnectorProvider2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐConnectorProvider[tmp] + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNConnectorProvider2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐConnectorProvider(ctx context.Context, sel ast.SelectionSet, v coredata.ConnectorProvider) graphql.Marshaler { + _ = sel + res := graphql.MarshalString(marshalNConnectorProvider2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐConnectorProvider[v]) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +var ( + unmarshalNConnectorProvider2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐConnectorProvider = map[string]coredata.ConnectorProvider{ + "SLACK": coredata.ConnectorProviderSlack, + "GOOGLE_WORKSPACE": coredata.ConnectorProviderGoogleWorkspace, + } + marshalNConnectorProvider2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐConnectorProvider = map[coredata.ConnectorProvider]string{ + coredata.ConnectorProviderSlack: "SLACK", + coredata.ConnectorProviderGoogleWorkspace: "GOOGLE_WORKSPACE", + } +) + func (ec *executionContext) unmarshalNCreateOrganizationInput2goᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐCreateOrganizationInput(ctx context.Context, v any) (types.CreateOrganizationInput, error) { res, err := ec.unmarshalInputCreateOrganizationInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) @@ -20458,6 +21567,62 @@ var ( } ) +func (ec *executionContext) unmarshalNSCIMBridgeState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐSCIMBridgeState(ctx context.Context, v any) (coredata.SCIMBridgeState, error) { + tmp, err := graphql.UnmarshalString(v) + res := unmarshalNSCIMBridgeState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐSCIMBridgeState[tmp] + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNSCIMBridgeState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐSCIMBridgeState(ctx context.Context, sel ast.SelectionSet, v coredata.SCIMBridgeState) graphql.Marshaler { + _ = sel + res := graphql.MarshalString(marshalNSCIMBridgeState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐSCIMBridgeState[v]) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +var ( + unmarshalNSCIMBridgeState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐSCIMBridgeState = map[string]coredata.SCIMBridgeState{ + "PENDING": coredata.SCIMBridgeStatePending, + "ACTIVE": coredata.SCIMBridgeStateActive, + "FAILED": coredata.SCIMBridgeStateFailed, + } + marshalNSCIMBridgeState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐSCIMBridgeState = map[coredata.SCIMBridgeState]string{ + coredata.SCIMBridgeStatePending: "PENDING", + coredata.SCIMBridgeStateActive: "ACTIVE", + coredata.SCIMBridgeStateFailed: "FAILED", + } +) + +func (ec *executionContext) unmarshalNSCIMBridgeType2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐSCIMBridgeType(ctx context.Context, v any) (coredata.SCIMBridgeType, error) { + tmp, err := graphql.UnmarshalString(v) + res := unmarshalNSCIMBridgeType2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐSCIMBridgeType[tmp] + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNSCIMBridgeType2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐSCIMBridgeType(ctx context.Context, sel ast.SelectionSet, v coredata.SCIMBridgeType) graphql.Marshaler { + _ = sel + res := graphql.MarshalString(marshalNSCIMBridgeType2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐSCIMBridgeType[v]) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +var ( + unmarshalNSCIMBridgeType2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐSCIMBridgeType = map[string]coredata.SCIMBridgeType{ + "GOOGLE_WORKSPACE": coredata.SCIMBridgeTypeGoogleWorkspace, + } + marshalNSCIMBridgeType2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐSCIMBridgeType = map[coredata.SCIMBridgeType]string{ + coredata.SCIMBridgeTypeGoogleWorkspace: "GOOGLE_WORKSPACE", + } +) + func (ec *executionContext) marshalNSCIMConfiguration2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐSCIMConfiguration(ctx context.Context, sel ast.SelectionSet, v *types.SCIMConfiguration) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { @@ -21058,6 +22223,13 @@ func (ec *executionContext) marshalOChangePasswordPayload2ᚖgoᚗproboᚗincᚋ return ec._ChangePasswordPayload(ctx, sel, v) } +func (ec *executionContext) marshalOConnector2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐConnector(ctx context.Context, sel ast.SelectionSet, v *types.Connector) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._Connector(ctx, sel, v) +} + func (ec *executionContext) marshalOCreateOrganizationPayload2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐCreateOrganizationPayload(ctx context.Context, sel ast.SelectionSet, v *types.CreateOrganizationPayload) graphql.Marshaler { if v == nil { return graphql.Null @@ -21164,6 +22336,24 @@ func (ec *executionContext) marshalOForgotPasswordPayload2ᚖgoᚗproboᚗincᚋ return ec._ForgotPasswordPayload(ctx, sel, v) } +func (ec *executionContext) unmarshalOID2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋgidᚐGID(ctx context.Context, v any) (*gid.GID, error) { + if v == nil { + return nil, nil + } + res, err := gid1.UnmarshalGIDScalar(v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOID2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋgidᚐGID(ctx context.Context, sel ast.SelectionSet, v *gid.GID) graphql.Marshaler { + if v == nil { + return graphql.Null + } + _ = sel + _ = ctx + res := gid1.MarshalGIDScalar(*v) + return res +} + func (ec *executionContext) marshalOIdentity2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐIdentity(ctx context.Context, sel ast.SelectionSet, v *types.Identity) graphql.Marshaler { if v == nil { return graphql.Null @@ -21350,6 +22540,13 @@ func (ec *executionContext) marshalOSAMLConfigurationConnection2ᚖgoᚗproboᚗ return ec._SAMLConfigurationConnection(ctx, sel, v) } +func (ec *executionContext) marshalOSCIMBridge2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐSCIMBridge(ctx context.Context, sel ast.SelectionSet, v *types.SCIMBridge) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._SCIMBridge(ctx, sel, v) +} + func (ec *executionContext) marshalOSCIMConfiguration2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐSCIMConfiguration(ctx context.Context, sel ast.SelectionSet, v *types.SCIMConfiguration) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/pkg/server/api/connect/v1/types/bridge.go b/pkg/server/api/connect/v1/types/bridge.go new file mode 100644 index 000000000..e8ebcdb49 --- /dev/null +++ b/pkg/server/api/connect/v1/types/bridge.go @@ -0,0 +1,38 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package types + +import "go.probo.inc/probo/pkg/coredata" + +func NewSCIMBridge(bridge *coredata.SCIMBridge) *SCIMBridge { + var connector *Connector + if bridge.ConnectorID != nil { + connector = &Connector{ + ID: *bridge.ConnectorID, + } + } + + return &SCIMBridge{ + ID: bridge.ID, + State: bridge.State, + ScimConfiguration: &SCIMConfiguration{ + ID: bridge.ScimConfigurationID, + }, + Connector: connector, + Type: bridge.Type, + CreatedAt: bridge.CreatedAt, + UpdatedAt: bridge.UpdatedAt, + } +} diff --git a/pkg/server/api/connect/v1/types/connector.go b/pkg/server/api/connect/v1/types/connector.go new file mode 100644 index 000000000..d3e2bc95e --- /dev/null +++ b/pkg/server/api/connect/v1/types/connector.go @@ -0,0 +1,26 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package types + +import "go.probo.inc/probo/pkg/coredata" + +func NewConnector(connector *coredata.Connector) *Connector { + return &Connector{ + ID: connector.ID, + Provider: connector.Provider, + CreatedAt: connector.CreatedAt, + UpdatedAt: connector.UpdatedAt, + } +} diff --git a/pkg/server/api/connect/v1/types/scim_configuration.go b/pkg/server/api/connect/v1/types/scim_configuration.go index acdd4b0d5..7ab51806e 100644 --- a/pkg/server/api/connect/v1/types/scim_configuration.go +++ b/pkg/server/api/connect/v1/types/scim_configuration.go @@ -19,12 +19,18 @@ import ( ) func NewSCIMConfiguration(scimConfiguration *coredata.SCIMConfiguration) *SCIMConfiguration { + var bridge *SCIMBridge + if scimConfiguration.BridgeID != nil { + bridge = &SCIMBridge{ + ID: *scimConfiguration.BridgeID, + } + } + return &SCIMConfiguration{ - ID: scimConfiguration.ID, - Organization: &Organization{ - ID: scimConfiguration.OrganizationID, - }, - CreatedAt: scimConfiguration.CreatedAt, - UpdatedAt: scimConfiguration.UpdatedAt, + ID: scimConfiguration.ID, + Organization: &Organization{ID: scimConfiguration.OrganizationID}, + Bridge: bridge, + CreatedAt: scimConfiguration.CreatedAt, + UpdatedAt: scimConfiguration.UpdatedAt, } } diff --git a/pkg/server/api/connect/v1/types/types.go b/pkg/server/api/connect/v1/types/types.go index 639e42279..40b84bd35 100644 --- a/pkg/server/api/connect/v1/types/types.go +++ b/pkg/server/api/connect/v1/types/types.go @@ -60,6 +60,17 @@ type ChangePasswordPayload struct { Success bool `json:"success"` } +type Connector struct { + ID gid.GID `json:"id"` + Provider coredata.ConnectorProvider `json:"provider"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Permission bool `json:"permission"` +} + +func (Connector) IsNode() {} +func (this Connector) GetID() gid.GID { return this.ID } + type CreateOrganizationInput struct { Name string `json:"name"` LogoFile *graphql.Upload `json:"logoFile,omitempty"` @@ -96,11 +107,13 @@ type CreateSAMLConfigurationPayload struct { } type CreateSCIMConfigurationInput struct { - OrganizationID gid.GID `json:"organizationId"` + OrganizationID gid.GID `json:"organizationId"` + ConnectorID *gid.GID `json:"connectorId,omitempty"` } type CreateSCIMConfigurationPayload struct { ScimConfiguration *SCIMConfiguration `json:"scimConfiguration"` + ScimBridge *SCIMBridge `json:"scimBridge,omitempty"` Token string `json:"token"` } @@ -396,12 +409,27 @@ type SAMLConfigurationEdge struct { Cursor page.CursorKey `json:"cursor"` } +type SCIMBridge struct { + ID gid.GID `json:"id"` + State coredata.SCIMBridgeState `json:"state"` + ScimConfiguration *SCIMConfiguration `json:"scimConfiguration,omitempty"` + Connector *Connector `json:"connector,omitempty"` + Type coredata.SCIMBridgeType `json:"type"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Permission bool `json:"permission"` +} + +func (SCIMBridge) IsNode() {} +func (this SCIMBridge) GetID() gid.GID { return this.ID } + type SCIMConfiguration struct { ID gid.GID `json:"id"` EndpointURL string `json:"endpointUrl"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` Organization *Organization `json:"organization,omitempty"` + Bridge *SCIMBridge `json:"bridge,omitempty"` Events *SCIMEventConnection `json:"events,omitempty"` Permission bool `json:"permission"` } diff --git a/pkg/server/api/connect/v1/v1_resolver.go b/pkg/server/api/connect/v1/v1_resolver.go index 2a629ab42..f01adc3fc 100644 --- a/pkg/server/api/connect/v1/v1_resolver.go +++ b/pkg/server/api/connect/v1/v1_resolver.go @@ -27,6 +27,11 @@ import ( "go.probo.inc/probo/pkg/server/gqlutils/types/cursor" ) +// Permission is the resolver for the permission field. +func (r *connectorResolver) Permission(ctx context.Context, obj *types.Connector, action string) (bool, error) { + return r.Resolver.Permission(ctx, obj, action) +} + // Memberships is the resolver for the memberships field. func (r *identityResolver) Memberships(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.MembershipOrderBy) (*types.MembershipConnection, error) { if err := r.authorize(ctx, obj.ID, iam.ActionMembershipList); err != nil { @@ -1119,10 +1124,24 @@ func (r *mutationResolver) CreateSCIMConfiguration(ctx context.Context, input ty return nil, gqlutils.Internal(ctx) } - return &types.CreateSCIMConfigurationPayload{ + var bridge *types.SCIMBridge + + if input.ConnectorID != nil { + scimBridge, err := r.iam.OrganizationService.CreateSCIMBridge(ctx, input.OrganizationID, config.ID, *input.ConnectorID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot create scim bridge", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + bridge = types.NewSCIMBridge(scimBridge) + } + + payload := &types.CreateSCIMConfigurationPayload{ ScimConfiguration: types.NewSCIMConfiguration(config), + ScimBridge: bridge, Token: token, - }, nil + } + + return payload, nil } // DeleteSCIMConfiguration is the resolver for the deleteSCIMConfiguration field. @@ -1578,6 +1597,63 @@ func (r *sAMLConfigurationConnectionResolver) TotalCount(ctx context.Context, ob return nil, gqlutils.Internal(ctx) } +// ScimConfiguration is the resolver for the scimConfiguration field. +func (r *sCIMBridgeResolver) ScimConfiguration(ctx context.Context, obj *types.SCIMBridge) (*types.SCIMConfiguration, error) { + if err := r.authorize(ctx, obj.ScimConfiguration.ID, iam.ActionSCIMConfigurationGet); err != nil { + return nil, err + } + + if gqlutils.OnlyIDSelected(ctx) { + return &types.SCIMConfiguration{ + ID: obj.ScimConfiguration.ID, + }, nil + } + + scimConfiguration, err := r.iam.GetSCIMConfiguration(ctx, obj.ScimConfiguration.ID) + if err != nil { + var errNoSCIMConfigurationFound *iam.ErrNoSCIMConfigurationFound + if errors.As(err, &errNoSCIMConfigurationFound) { + return nil, nil + } + + return nil, err + } + + return types.NewSCIMConfiguration(scimConfiguration), nil +} + +// Connector is the resolver for the connector field. +func (r *sCIMBridgeResolver) Connector(ctx context.Context, obj *types.SCIMBridge) (*types.Connector, error) { + if obj.Connector == nil { + return nil, nil + } + + // Authorize based on the SCIM configuration (connector accessed via bridge is a sub-resource) + if err := r.authorize(ctx, obj.ScimConfiguration.ID, iam.ActionSCIMConfigurationGet); err != nil { + return nil, err + } + + if gqlutils.OnlyIDSelected(ctx) { + return &types.Connector{ + ID: obj.Connector.ID, + }, nil + } + + // Use metadata-only loading since we don't need the encrypted connection data + connector, err := r.iam.OrganizationService.GetConnectorMetadataByID(ctx, obj.Connector.ID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get connector", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewConnector(connector), nil +} + +// Permission is the resolver for the permission field. +func (r *sCIMBridgeResolver) Permission(ctx context.Context, obj *types.SCIMBridge, action string) (bool, error) { + return r.Resolver.Permission(ctx, obj, action) +} + // EndpointURL is the resolver for the endpointUrl field. func (r *sCIMConfigurationResolver) EndpointURL(ctx context.Context, obj *types.SCIMConfiguration) (string, error) { return r.baseURL.WithPath("/api/connect/v1/scim/2.0").MustString(), nil @@ -1609,6 +1685,31 @@ func (r *sCIMConfigurationResolver) Organization(ctx context.Context, obj *types return types.NewOrganization(organization), nil } +// Bridge is the resolver for the bridge field. +func (r *sCIMConfigurationResolver) Bridge(ctx context.Context, obj *types.SCIMConfiguration) (*types.SCIMBridge, error) { + + if obj.Bridge == nil { + return nil, nil + } + + if err := r.authorize(ctx, obj.ID, iam.ActionSCIMConfigurationGet); err != nil { + return nil, err + } + + bridge, err := r.iam.OrganizationService.GetSCIMBridgeByID(ctx, obj.Bridge.ID) + if err != nil { + var errSCIMBridgeNotFound *iam.ErrSCIMBridgeNotFound + if errors.As(err, &errSCIMBridgeNotFound) { + return nil, nil + } + + r.logger.ErrorCtx(ctx, "cannot get scim bridge", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewSCIMBridge(bridge), nil +} + // Events is the resolver for the events field. func (r *sCIMConfigurationResolver) Events(ctx context.Context, obj *types.SCIMConfiguration, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.SCIMEventOrderBy) (*types.SCIMEventConnection, error) { if err := r.authorize(ctx, obj.ID, iam.ActionSCIMEventList); err != nil { @@ -1734,6 +1835,9 @@ func (r *sessionConnectionResolver) TotalCount(ctx context.Context, obj *types.S return nil, gqlutils.Internal(ctx) } +// Connector returns schema.ConnectorResolver implementation. +func (r *Resolver) Connector() schema.ConnectorResolver { return &connectorResolver{r} } + // Identity returns schema.IdentityResolver implementation. func (r *Resolver) Identity() schema.IdentityResolver { return &identityResolver{r} } @@ -1785,6 +1889,9 @@ func (r *Resolver) SAMLConfigurationConnection() schema.SAMLConfigurationConnect return &sAMLConfigurationConnectionResolver{r} } +// SCIMBridge returns schema.SCIMBridgeResolver implementation. +func (r *Resolver) SCIMBridge() schema.SCIMBridgeResolver { return &sCIMBridgeResolver{r} } + // SCIMConfiguration returns schema.SCIMConfigurationResolver implementation. func (r *Resolver) SCIMConfiguration() schema.SCIMConfigurationResolver { return &sCIMConfigurationResolver{r} @@ -1806,6 +1913,7 @@ func (r *Resolver) SessionConnection() schema.SessionConnectionResolver { return &sessionConnectionResolver{r} } +type connectorResolver struct{ *Resolver } type identityResolver struct{ *Resolver } type invitationResolver struct{ *Resolver } type invitationConnectionResolver struct{ *Resolver } @@ -1819,6 +1927,7 @@ type personalAPIKeyConnectionResolver struct{ *Resolver } type queryResolver struct{ *Resolver } type sAMLConfigurationResolver struct{ *Resolver } type sAMLConfigurationConnectionResolver struct{ *Resolver } +type sCIMBridgeResolver struct{ *Resolver } type sCIMConfigurationResolver struct{ *Resolver } type sCIMEventResolver struct{ *Resolver } type sCIMEventConnectionResolver struct{ *Resolver } diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index f8fd6fa77..a7fb59217 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -21,6 +21,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strings" "github.com/go-chi/chi/v5" @@ -76,7 +77,7 @@ func NewMux( r.Get("/connectors/initiate", func(w http.ResponseWriter, r *http.Request) { provider := r.URL.Query().Get("provider") - if provider != "SLACK" { + if provider != "SLACK" && provider != "GOOGLE_WORKSPACE" { httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("unsupported provider")) return } @@ -118,9 +119,15 @@ func NewMux( panic(fmt.Errorf("cannot initiate connector: %w", err)) } - // Allow external redirects for Slack OAuth only for now - slackSafeRedirect := &saferedirect.SafeRedirect{AllowedHost: "slack.com"} - slackSafeRedirect.Redirect(w, r, redirectURL, "/", http.StatusSeeOther) + // Allow external redirects for OAuth providers + var oauthSafeRedirect *saferedirect.SafeRedirect + switch provider { + case "SLACK": + oauthSafeRedirect = &saferedirect.SafeRedirect{AllowedHost: "slack.com"} + case "GOOGLE_WORKSPACE": + oauthSafeRedirect = &saferedirect.SafeRedirect{AllowedHost: "accounts.google.com"} + } + oauthSafeRedirect.Redirect(w, r, redirectURL, "/", http.StatusSeeOther) }) r.Get("/connectors/complete", func(w http.ResponseWriter, r *http.Request) { @@ -134,6 +141,8 @@ func NewMux( switch provider { case "SLACK": connectorProvider = coredata.ConnectorProviderSlack + case "GOOGLE_WORKSPACE": + connectorProvider = coredata.ConnectorProviderGoogleWorkspace default: httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("unsupported provider")) return @@ -145,16 +154,14 @@ func NewMux( return } - connection, organizationID, err := connectorRegistry.Complete(r.Context(), provider, r) + connection, organizationID, continueURL, err := connectorRegistry.Complete(r.Context(), provider, r) if err != nil { panic(fmt.Errorf("cannot complete connector: %w", err)) } - continueURL := r.URL.Query().Get("continue") - svc := proboSvc.WithTenant(organizationID.TenantID()) - _, err = svc.Connectors.Create( + connector, err := svc.Connectors.Create( r.Context(), probo.CreateConnectorRequest{ OrganizationID: *organizationID, @@ -167,12 +174,22 @@ func NewMux( panic(fmt.Errorf("cannot create or update connector: %w", err)) } - if continueURL != "" { - safeRedirect.Redirect(w, r, continueURL, "/", http.StatusSeeOther) - } else { - redirectURL := baseURL.WithPath("/organizations/" + organizationID.String()).MustString() - safeRedirect.Redirect(w, r, redirectURL, "/", http.StatusSeeOther) + // Append connector_id to the redirect URL so frontend can create the bridge + redirectURL := continueURL + if redirectURL == "" { + redirectURL = baseURL.WithPath("/organizations/" + organizationID.String()).MustString() } + + parsedURL, err := url.Parse(redirectURL) + if err != nil { + logger.ErrorCtx(r.Context(), "cannot parse redirect URL", log.Error(err)) + parsedURL, _ = url.Parse(baseURL.WithPath("/organizations/" + organizationID.String()).MustString()) + } + q := parsedURL.Query() + q.Set("connector_id", connector.ID.String()) + parsedURL.RawQuery = q.Encode() + + safeRedirect.Redirect(w, r, parsedURL.String(), "/", http.StatusSeeOther) }) })