From 56ec0ab3c3fce310c2908a1db7313d5a5b4e3abf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Fri, 13 Feb 2026 21:21:11 +0400 Subject: [PATCH] Move source and state from membership to profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- ...PeopleListFragment_RefetchQuery.graphql.ts | 34 +- .../iam/PeopleListItemFragment.graphql.ts | 39 +- .../iam/PeoplePageQuery.graphql.ts | 34 +- .../iam/SCIMEventListItemFragment.graphql.ts | 23 +- .../SCIMEventListPaginationQuery.graphql.ts | 22 +- .../iam/SCIMSettingsPageQuery.graphql.ts | 22 +- .../people/_components/PeopleListItem.tsx | 8 +- .../_components/SCIMEventListItem.tsx | 7 +- pkg/coredata/membership.go | 222 ++------- pkg/coredata/membership_filter.go | 108 ----- pkg/coredata/membership_profile.go | 187 ++++++-- pkg/coredata/membership_profile_filter.go | 60 ++- pkg/coredata/migrations/20260212T163603Z.sql | 44 ++ ...membership_source.go => profile_source.go} | 24 +- .../{membership_state.go => profile_state.go} | 20 +- pkg/coredata/scim_event.go | 14 +- pkg/iam/account_service.go | 113 +++-- pkg/iam/authorizer.go | 42 +- pkg/iam/errors.go | 36 +- pkg/iam/organization_service.go | 122 ++--- pkg/iam/saml/errors.go | 10 +- pkg/iam/saml/service.go | 70 +-- pkg/iam/scim/filter.go | 4 +- pkg/iam/scim/service.go | 356 +++++++------- pkg/iam/session_service.go | 50 +- pkg/server/api/connect/v1/schema.graphql | 58 +-- pkg/server/api/connect/v1/schema/schema.go | 442 +++++++----------- pkg/server/api/connect/v1/scim_handler.go | 72 +-- pkg/server/api/connect/v1/types/membership.go | 2 - pkg/server/api/connect/v1/types/profile.go | 2 + pkg/server/api/connect/v1/types/scim_event.go | 8 +- pkg/server/api/connect/v1/types/types.go | 16 +- pkg/server/api/connect/v1/v1_resolver.go | 15 +- 33 files changed, 1066 insertions(+), 1220 deletions(-) delete mode 100644 pkg/coredata/membership_filter.go rename pkg/coredata/{membership_source.go => profile_source.go} (66%) rename pkg/coredata/{membership_state.go => profile_state.go} (69%) diff --git a/apps/console/src/__generated__/iam/PeopleListFragment_RefetchQuery.graphql.ts b/apps/console/src/__generated__/iam/PeopleListFragment_RefetchQuery.graphql.ts index 471c0e223..025ce557f 100644 --- a/apps/console/src/__generated__/iam/PeopleListFragment_RefetchQuery.graphql.ts +++ b/apps/console/src/__generated__/iam/PeopleListFragment_RefetchQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -222,6 +222,20 @@ return { "plural": false, "selections": [ (v12/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "source", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "state", + "storageKey": null + }, { "alias": null, "args": null, @@ -259,20 +273,6 @@ return { "name": "role", "storageKey": null }, - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "source", - "storageKey": null - }, - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "state", - "storageKey": null - }, { "alias": "canUpdate", "args": [ @@ -430,12 +430,12 @@ return { ] }, "params": { - "cacheID": "25aa17e466fdec627ba3d24d5230301b", + "cacheID": "cc77d44877ae736c858e7458b525d75a", "id": null, "metadata": {}, "name": "PeopleListFragment_RefetchQuery", "operationKind": "query", - "text": "query PeopleListFragment_RefetchQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 20\n $last: Int = null\n $order: ProfileOrder = {direction: ASC, field: FULL_NAME}\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...PeopleListFragment_16fISc\n id\n }\n}\n\nfragment PeopleListFragment_16fISc on Organization {\n profiles(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n totalCount\n edges {\n node {\n id\n ...PeopleListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment PeopleListItemFragment on Profile {\n id\n fullName\n kind\n position\n membership {\n id\n role\n source\n state\n canUpdate: permission(action: \"iam:membership:update\")\n canDelete: permission(action: \"iam:membership-profile:delete\")\n }\n identity {\n email\n id\n }\n createdAt\n canUpdate: permission(action: \"iam:membership-profile:update\")\n}\n" + "text": "query PeopleListFragment_RefetchQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 20\n $last: Int = null\n $order: ProfileOrder = {direction: ASC, field: FULL_NAME}\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...PeopleListFragment_16fISc\n id\n }\n}\n\nfragment PeopleListFragment_16fISc on Organization {\n profiles(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n totalCount\n edges {\n node {\n id\n ...PeopleListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment PeopleListItemFragment on Profile {\n id\n source\n state\n fullName\n kind\n position\n membership {\n id\n role\n canUpdate: permission(action: \"iam:membership:update\")\n canDelete: permission(action: \"iam:membership-profile:delete\")\n }\n identity {\n email\n id\n }\n createdAt\n canUpdate: permission(action: \"iam:membership-profile:update\")\n}\n" } }; })(); diff --git a/apps/console/src/__generated__/iam/PeopleListItemFragment.graphql.ts b/apps/console/src/__generated__/iam/PeopleListItemFragment.graphql.ts index 4be288a77..e8b587ae2 100644 --- a/apps/console/src/__generated__/iam/PeopleListItemFragment.graphql.ts +++ b/apps/console/src/__generated__/iam/PeopleListItemFragment.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<6e9b0a4fbf6ce65f11f2367f2ec5b6d4>> + * @generated SignedSource<<67410bc0aa6e9426f4534a0938429b6e>> * @lightSyntaxTransform * @nogrep */ @@ -10,9 +10,8 @@ import { ReaderFragment } from 'relay-runtime'; export type MembershipRole = "ADMIN" | "AUDITOR" | "EMPLOYEE" | "OWNER" | "VIEWER"; -export type MembershipSource = "MANUAL" | "SAML" | "SCIM"; -export type MembershipState = "ACTIVE" | "INACTIVE"; export type ProfileKind = "CONTRACTOR" | "EMPLOYEE" | "SERVICE_ACCOUNT"; +export type ProfileState = "ACTIVE" | "INACTIVE"; import { FragmentRefs } from "relay-runtime"; export type PeopleListItemFragment$data = { readonly canUpdate: boolean; @@ -28,10 +27,10 @@ export type PeopleListItemFragment$data = { readonly canUpdate: boolean; readonly id: string; readonly role: MembershipRole; - readonly source: MembershipSource; - readonly state: MembershipState; }; readonly position: string | null | undefined; + readonly source: string; + readonly state: ProfileState; readonly " $fragmentType": "PeopleListItemFragment"; }; export type PeopleListItemFragment$key = { @@ -54,6 +53,20 @@ return { "name": "PeopleListItemFragment", "selections": [ (v0/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "source", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "state", + "storageKey": null + }, { "alias": null, "args": null, @@ -93,20 +106,6 @@ return { "name": "role", "storageKey": null }, - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "source", - "storageKey": null - }, - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "state", - "storageKey": null - }, { "alias": "canUpdate", "args": [ @@ -186,6 +185,6 @@ return { }; })(); -(node as any).hash = "89702fbdf02294212269adb22efb65fe"; +(node as any).hash = "ad9ed05edf606ec084c6f53462530b8b"; export default node; diff --git a/apps/console/src/__generated__/iam/PeoplePageQuery.graphql.ts b/apps/console/src/__generated__/iam/PeoplePageQuery.graphql.ts index 90cf540cd..dfd9be2a3 100644 --- a/apps/console/src/__generated__/iam/PeoplePageQuery.graphql.ts +++ b/apps/console/src/__generated__/iam/PeoplePageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<7f025767ad277b8b8de6cbe9b1490312>> * @lightSyntaxTransform * @nogrep */ @@ -345,6 +345,20 @@ return { "plural": false, "selections": [ (v10/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "source", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "state", + "storageKey": null + }, (v11/*: any*/), { "alias": null, @@ -370,20 +384,6 @@ return { "selections": [ (v10/*: any*/), (v12/*: any*/), - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "source", - "storageKey": null - }, - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "state", - "storageKey": null - }, { "alias": "canUpdate", "args": [ @@ -552,12 +552,12 @@ return { ] }, "params": { - "cacheID": "4303ec6c2fa72181d747bb9053446aaa", + "cacheID": "b67de1f40d556a5e4a6d78104e2eb224", "id": null, "metadata": {}, "name": "PeoplePageQuery", "operationKind": "query", - "text": "query PeoplePageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n canInviteUser: permission(action: \"iam:invitation:create\")\n profiles(first: 20, orderBy: {direction: ASC, field: FULL_NAME}) {\n totalCount\n }\n ...PeopleListFragment_8lnpd\n invitations(first: 20, orderBy: {direction: DESC, field: CREATED_AT}) {\n totalCount\n __typename\n }\n ...InvitationListFragment_1PypFi\n }\n id\n }\n}\n\nfragment InvitationListFragment_1PypFi on Organization {\n invitations(first: 20, orderBy: {direction: DESC, field: CREATED_AT}) {\n edges {\n node {\n id\n ...InvitationListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment InvitationListItemFragment on Invitation {\n id\n fullName\n email\n role\n status\n createdAt\n acceptedAt\n canDelete: permission(action: \"iam:invitation:delete\")\n}\n\nfragment PeopleListFragment_8lnpd on Organization {\n profiles(first: 20, orderBy: {direction: ASC, field: FULL_NAME}) {\n totalCount\n edges {\n node {\n id\n ...PeopleListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment PeopleListItemFragment on Profile {\n id\n fullName\n kind\n position\n membership {\n id\n role\n source\n state\n canUpdate: permission(action: \"iam:membership:update\")\n canDelete: permission(action: \"iam:membership-profile:delete\")\n }\n identity {\n email\n id\n }\n createdAt\n canUpdate: permission(action: \"iam:membership-profile:update\")\n}\n" + "text": "query PeoplePageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n canInviteUser: permission(action: \"iam:invitation:create\")\n profiles(first: 20, orderBy: {direction: ASC, field: FULL_NAME}) {\n totalCount\n }\n ...PeopleListFragment_8lnpd\n invitations(first: 20, orderBy: {direction: DESC, field: CREATED_AT}) {\n totalCount\n __typename\n }\n ...InvitationListFragment_1PypFi\n }\n id\n }\n}\n\nfragment InvitationListFragment_1PypFi on Organization {\n invitations(first: 20, orderBy: {direction: DESC, field: CREATED_AT}) {\n edges {\n node {\n id\n ...InvitationListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment InvitationListItemFragment on Invitation {\n id\n fullName\n email\n role\n status\n createdAt\n acceptedAt\n canDelete: permission(action: \"iam:invitation:delete\")\n}\n\nfragment PeopleListFragment_8lnpd on Organization {\n profiles(first: 20, orderBy: {direction: ASC, field: FULL_NAME}) {\n totalCount\n edges {\n node {\n id\n ...PeopleListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment PeopleListItemFragment on Profile {\n id\n source\n state\n fullName\n kind\n position\n membership {\n id\n role\n canUpdate: permission(action: \"iam:membership:update\")\n canDelete: permission(action: \"iam:membership-profile:delete\")\n }\n identity {\n email\n id\n }\n createdAt\n canUpdate: permission(action: \"iam:membership-profile:update\")\n}\n" } }; })(); diff --git a/apps/console/src/__generated__/iam/SCIMEventListItemFragment.graphql.ts b/apps/console/src/__generated__/iam/SCIMEventListItemFragment.graphql.ts index 8d29f6ed4..206629420 100644 --- a/apps/console/src/__generated__/iam/SCIMEventListItemFragment.graphql.ts +++ b/apps/console/src/__generated__/iam/SCIMEventListItemFragment.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<6bc4cdb83f5825b5d606fe579fa8f7a8>> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -16,10 +16,8 @@ export type SCIMEventListItemFragment$data = { readonly ipAddress: string; readonly method: string; readonly path: string; - readonly profile: { - readonly fullName: string; - } | null | undefined; readonly statusCode: number; + readonly userName: string; readonly " $fragmentType": "SCIMEventListItemFragment"; }; export type SCIMEventListItemFragment$key = { @@ -78,19 +76,8 @@ const node: ReaderFragment = { { "alias": null, "args": null, - "concreteType": "Profile", - "kind": "LinkedField", - "name": "profile", - "plural": false, - "selections": [ - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "fullName", - "storageKey": null - } - ], + "kind": "ScalarField", + "name": "userName", "storageKey": null } ], @@ -98,6 +85,6 @@ const node: ReaderFragment = { "abstractKey": null }; -(node as any).hash = "b38af9ac9b660d3aab75e00e2c55dfb6"; +(node as any).hash = "9df2e986643ab90589ebc6e4631cde24"; export default node; diff --git a/apps/console/src/__generated__/iam/SCIMEventListPaginationQuery.graphql.ts b/apps/console/src/__generated__/iam/SCIMEventListPaginationQuery.graphql.ts index 335e1fab0..102604807 100644 --- a/apps/console/src/__generated__/iam/SCIMEventListPaginationQuery.graphql.ts +++ b/apps/console/src/__generated__/iam/SCIMEventListPaginationQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<22d63f81020d1e99f7cdb84fc23913a4>> + * @generated SignedSource<<08f3ffe97dd35388d5ce01977170773b>> * @lightSyntaxTransform * @nogrep */ @@ -224,20 +224,8 @@ return { { "alias": null, "args": null, - "concreteType": "Profile", - "kind": "LinkedField", - "name": "profile", - "plural": false, - "selections": [ - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "fullName", - "storageKey": null - }, - (v8/*: any*/) - ], + "kind": "ScalarField", + "name": "userName", "storageKey": null }, (v7/*: any*/) @@ -315,12 +303,12 @@ return { ] }, "params": { - "cacheID": "58b36de1b4c1f54de6433a95b297b570", + "cacheID": "5e80fcd5f3479d5b6bce65b59364d3f7", "id": null, "metadata": {}, "name": "SCIMEventListPaginationQuery", "operationKind": "query", - "text": "query SCIMEventListPaginationQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 20\n $last: Int = null\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...SCIMEventListFragment_pbnwq\n id\n }\n}\n\nfragment SCIMEventListFragment_pbnwq on SCIMConfiguration {\n events(first: $first, after: $after, last: $last, before: $before) {\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 method\n path\n statusCode\n errorMessage\n ipAddress\n createdAt\n profile {\n fullName\n id\n }\n}\n" + "text": "query SCIMEventListPaginationQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 20\n $last: Int = null\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...SCIMEventListFragment_pbnwq\n id\n }\n}\n\nfragment SCIMEventListFragment_pbnwq on SCIMConfiguration {\n events(first: $first, after: $after, last: $last, before: $before) {\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 method\n path\n statusCode\n errorMessage\n ipAddress\n createdAt\n userName\n}\n" } }; })(); diff --git a/apps/console/src/__generated__/iam/SCIMSettingsPageQuery.graphql.ts b/apps/console/src/__generated__/iam/SCIMSettingsPageQuery.graphql.ts index bd81109ee..ad78974fa 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<<339e086642b09eb0bacbc51601014fe7>> + * @generated SignedSource<<164d2e51d3c593d5effe16958d2d8f0e>> * @lightSyntaxTransform * @nogrep */ @@ -277,20 +277,8 @@ return { { "alias": null, "args": null, - "concreteType": "Profile", - "kind": "LinkedField", - "name": "profile", - "plural": false, - "selections": [ - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "fullName", - "storageKey": null - }, - (v3/*: any*/) - ], + "kind": "ScalarField", + "name": "userName", "storageKey": null }, (v2/*: any*/) @@ -404,12 +392,12 @@ return { ] }, "params": { - "cacheID": "3d49152bd3fd384a0b1de3d012268ebe", + "cacheID": "b98c546a2c8fc038c121c21f3b495a7d", "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 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 id\n excludedUserNames\n connector {\n id\n createdAt\n }\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 method\n path\n statusCode\n errorMessage\n ipAddress\n createdAt\n profile {\n fullName\n id\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 id\n excludedUserNames\n connector {\n id\n createdAt\n }\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 method\n path\n statusCode\n errorMessage\n ipAddress\n createdAt\n userName\n}\n" } }; })(); diff --git a/apps/console/src/pages/iam/organizations/people/_components/PeopleListItem.tsx b/apps/console/src/pages/iam/organizations/people/_components/PeopleListItem.tsx index 99afcd4c9..6f5bdf5c2 100644 --- a/apps/console/src/pages/iam/organizations/people/_components/PeopleListItem.tsx +++ b/apps/console/src/pages/iam/organizations/people/_components/PeopleListItem.tsx @@ -24,14 +24,14 @@ import { EditMemberDialog } from "../../settings/_components/EditMemberDialog"; const fragment = graphql` fragment PeopleListItemFragment on Profile { id + source + state fullName kind position membership @required(action: THROW) { id role - source - state canUpdate: permission(action: "iam:membership:update") canDelete: permission(action: "iam:membership-profile:delete") } @@ -68,7 +68,7 @@ export function MemberListItem(props: { const profile = useFragment(fragment, fKey); - const isInactive = profile.membership.state === "INACTIVE"; + const isInactive = profile.state === "INACTIVE"; const [removeMembership, isRemoving] = useMutationWithToasts( removeMemberMutation, @@ -118,7 +118,7 @@ export function MemberListItem(props: {
{profile.identity.email} - {profile.membership.source} + {profile.source}
{profile.kind} diff --git a/apps/console/src/pages/iam/organizations/settings/_components/SCIMEventListItem.tsx b/apps/console/src/pages/iam/organizations/settings/_components/SCIMEventListItem.tsx index 4a7a6c090..df7a66dc1 100644 --- a/apps/console/src/pages/iam/organizations/settings/_components/SCIMEventListItem.tsx +++ b/apps/console/src/pages/iam/organizations/settings/_components/SCIMEventListItem.tsx @@ -14,9 +14,7 @@ const SCIMEventListItemFragment = graphql` errorMessage ipAddress createdAt - profile { - fullName - } + userName } `; @@ -89,8 +87,7 @@ export function SCIMEventListItem(props: {
User: - {/* FIXME */} - {/* {event.membership?.profile?.fullName || "-"} */} + {event.userName || "-"}
IP Address: diff --git a/pkg/coredata/membership.go b/pkg/coredata/membership.go index 77b783004..93571e025 100644 --- a/pkg/coredata/membership.go +++ b/pkg/coredata/membership.go @@ -25,24 +25,17 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" - "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/page" ) type ( Membership struct { - ID gid.GID `db:"id"` - IdentityID gid.GID `db:"identity_id"` - OrganizationID gid.GID `db:"organization_id"` - Role MembershipRole `db:"role"` - Source MembershipSource `db:"source"` - State MembershipState `db:"state"` - // FIXME: remove after scim is based on profile - EmailAddress mail.Addr `db:"-"` - FullName string `db:"-"` - - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID gid.GID `db:"id"` + IdentityID gid.GID `db:"identity_id"` + OrganizationID gid.GID `db:"organization_id"` + Role MembershipRole `db:"role"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } Memberships []*Membership @@ -66,8 +59,6 @@ SELECT identity_id, organization_id, role, - source, - state, created_at, updated_at FROM @@ -109,8 +100,6 @@ INSERT INTO identity_id, organization_id, role, - source, - state, created_at, updated_at ) @@ -120,8 +109,6 @@ VALUES ( @identity_id, @organization_id, @role, - @source, - @state, @created_at, @updated_at ); @@ -133,8 +120,6 @@ VALUES ( "identity_id": m.IdentityID, "organization_id": m.OrganizationID, "role": m.Role, - "source": m.Source, - "state": m.State, "created_at": m.CreatedAt, "updated_at": m.UpdatedAt, } @@ -168,8 +153,6 @@ SELECT identity_id, organization_id, role, - source, - state, created_at, updated_at FROM @@ -209,8 +192,7 @@ func (m *Membership) AuthorizationAttributes(ctx context.Context, conn pg.Conn) SELECT identity_id, organization_id, - role, - source + role FROM iam_memberships WHERE @@ -221,12 +203,10 @@ LIMIT 1; var identityID gid.GID var organizationID gid.GID var role MembershipRole - var source MembershipSource if err := conn.QueryRow(ctx, q, m.ID).Scan( &identityID, &organizationID, &role, - &source, ); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrResourceNotFound @@ -238,7 +218,6 @@ LIMIT 1; "identity_id": identityID.String(), "organization_id": organizationID.String(), "role": role.String(), - "source": source.String(), }, nil } @@ -255,8 +234,6 @@ SELECT identity_id, organization_id, role, - source, - state, created_at, updated_at FROM @@ -299,8 +276,6 @@ UPDATE iam_memberships SET role = @role, - source = @source, - state = @state, updated_at = @updated_at WHERE id = @id @@ -312,8 +287,6 @@ WHERE args := pgx.StrictNamedArgs{ "id": m.ID, "role": m.Role, - "source": m.Source, - "state": m.State, "updated_at": m.UpdatedAt, } maps.Copy(args, scope.SQLArguments()) @@ -358,180 +331,51 @@ WHERE return nil } -func (m *Memberships) LoadByOrganizationID( +func (m *Membership) LoadActiveByIdentityIDAndOrganizationID( ctx context.Context, conn pg.Conn, - scope Scoper, + identityID gid.GID, organizationID gid.GID, - cursor *page.Cursor[MembershipOrderField], - filter *MembershipFilter, ) error { - query := ` + q := ` SELECT - id, - identity_id, - organization_id, - role, - source, - state, - created_at, - updated_at -FROM - iam_memberships -WHERE - %s - AND %s - organization_id = @organization_id - AND %s -` - - query = fmt.Sprintf(query, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) - - args := pgx.StrictNamedArgs{ - "organization_id": organizationID, - } - maps.Copy(args, scope.SQLArguments()) - maps.Copy(args, filter.SQLArguments()) - maps.Copy(args, cursor.SQLArguments()) - - rows, err := conn.Query(ctx, query, args) - if err != nil { - return fmt.Errorf("cannot query memberships: %w", err) - } - - memberships, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Membership]) - if err != nil { - return fmt.Errorf("cannot collect memberships: %w", err) - } - - *m = memberships - return nil -} - -func (m *Memberships) CountByOrganizationID( - ctx context.Context, - conn pg.Conn, - scope Scoper, - organizationID gid.GID, - filter *MembershipFilter, -) (int, error) { - query := ` -SELECT - COUNT(*) + m.id, + m.identity_id, + m.organization_id, + m.role, + m.created_at, + m.updated_at FROM iam_memberships m -JOIN - identities i ON m.identity_id = i.id +INNER JOIN iam_membership_profiles p + ON p.identity_id = m.identity_id AND p.organization_id = m.organization_id WHERE - m.organization_id = @organization_id - AND m.%s - AND %s + p.state = @state + AND m.identity_id = @identity_id + AND m.organization_id = @organization_id +LIMIT 1 ` - query = fmt.Sprintf(query, scope.SQLFragment(), filter.SQLFragment()) + args := pgx.StrictNamedArgs{ + "state": ProfileStateActive, + "identity_id": identityID, "organization_id": organizationID, } - maps.Copy(args, scope.SQLArguments()) - maps.Copy(args, filter.SQLArguments()) - row := conn.QueryRow(ctx, query, args) - var count int - if err := row.Scan(&count); err != nil { - return 0, fmt.Errorf("cannot count memberships: %w", err) - } - return count, nil -} -func (m *Memberships) CountByIdentityID( - ctx context.Context, - conn pg.Conn, - identityID gid.GID, -) (int, error) { - query := ` -SELECT - COUNT(*) -FROM - iam_memberships -WHERE - identity_id = @identity_id - AND state = 'ACTIVE' -` - args := pgx.StrictNamedArgs{ - "identity_id": identityID, - } - - row := conn.QueryRow(ctx, query, args) - var count int - if err := row.Scan(&count); err != nil { - return 0, fmt.Errorf("cannot count memberships: %w", err) - } - - return count, nil -} - -func (m *Memberships) LoadAllByIdentityID( - ctx context.Context, - conn pg.Conn, - identityID gid.GID, -) error { - q := ` -SELECT - id, - identity_id, - organization_id, - role, - source, - state, - created_at, - updated_at -FROM - iam_memberships -WHERE - identity_id = $1 -; -` - - rows, err := conn.Query(ctx, q, identityID) + rows, err := conn.Query(ctx, q, args) if err != nil { return fmt.Errorf("cannot query memberships: %w", err) } - memberships, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Membership]) + membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToAddrOfStructByName[Membership]) if err != nil { - return fmt.Errorf("cannot collect memberships: %w", err) - } - - *m = memberships - return nil -} - -func (m *Memberships) ResetSCIMSources( - ctx context.Context, - conn pg.Conn, - scope Scoper, - organizationID gid.GID, -) error { - q := ` -UPDATE iam_memberships -SET - source = 'MANUAL', - updated_at = @updated_at -WHERE - %s - AND organization_id = @organization_id - AND source = 'SCIM' -` - q = fmt.Sprintf(q, scope.SQLFragment()) - - args := pgx.NamedArgs{ - "organization_id": organizationID, - "updated_at": time.Now(), - } - maps.Copy(args, scope.SQLArguments()) - - _, err := conn.Exec(ctx, q, args) - if err != nil { - return fmt.Errorf("cannot reset SCIM membership sources: %w", err) + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect membership: %w", err) } + *m = *membership return nil } diff --git a/pkg/coredata/membership_filter.go b/pkg/coredata/membership_filter.go deleted file mode 100644 index 18102055c..000000000 --- a/pkg/coredata/membership_filter.go +++ /dev/null @@ -1,108 +0,0 @@ -// 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 ( - "github.com/jackc/pgx/v5" - "go.probo.inc/probo/pkg/mail" -) - -type MembershipFilter struct { - email *mail.Addr - role *MembershipRole - state *MembershipState - source *MembershipSource -} - -func NewMembershipFilter() *MembershipFilter { - return &MembershipFilter{} -} - -func (f *MembershipFilter) WithEmail(email *mail.Addr) *MembershipFilter { - f.email = email - return f -} - -func (f *MembershipFilter) Email() *mail.Addr { - return f.email -} - -func (f *MembershipFilter) WithRole(role MembershipRole) *MembershipFilter { - f.role = &role - return f -} - -func (f *MembershipFilter) Role() *MembershipRole { - return f.role -} - -func (f *MembershipFilter) WithState(state MembershipState) *MembershipFilter { - f.state = &state - return f -} - -func (f *MembershipFilter) State() *MembershipState { - return f.state -} - -func (f *MembershipFilter) WithSource(source MembershipSource) *MembershipFilter { - f.source = &source - return f -} - -func (f *MembershipFilter) Source() *MembershipSource { - return f.source -} - -func (f *MembershipFilter) SQLArguments() pgx.StrictNamedArgs { - return pgx.StrictNamedArgs{ - "filter_email": f.email, - "filter_role": f.role, - "filter_state": f.state, - "filter_source": f.source, - } -} - -func (f *MembershipFilter) SQLFragment() string { - return ` -( - CASE - WHEN @filter_email::text IS NOT NULL THEN - i.email_address = @filter_email::text - ELSE TRUE - END -) -AND ( - CASE - WHEN @filter_role::text IS NOT NULL THEN - m.role = @filter_role::authz_role - ELSE TRUE - END -) -AND ( - CASE - WHEN @filter_state::text IS NOT NULL THEN - m.state = @filter_state::membership_state - ELSE TRUE - END -) -AND ( - CASE - WHEN @filter_source::text IS NOT NULL THEN - m.source = @filter_source::text - ELSE TRUE - END -)` -} diff --git a/pkg/coredata/membership_profile.go b/pkg/coredata/membership_profile.go index 75762b532..7349b39fb 100644 --- a/pkg/coredata/membership_profile.go +++ b/pkg/coredata/membership_profile.go @@ -34,6 +34,8 @@ type ( IdentityID gid.GID `db:"identity_id"` OrganizationID gid.GID `db:"organization_id"` EmailAddress mail.Addr `db:"email_address"` + Source ProfileSource `db:"source"` + State ProfileState `db:"state"` FullName string `db:"full_name"` Kind MembershipProfileKind `db:"kind"` AdditionalEmailAddresses mail.Addrs `db:"additional_email_addresses"` @@ -93,6 +95,8 @@ SELECT p.identity_id, p.organization_id, i.email_address, + p.source, + p.state, p.full_name, p.kind, p.additional_email_addresses, @@ -149,6 +153,8 @@ SELECT p.identity_id, p.organization_id, i.email_address, + p.source, + p.state, p.full_name, p.kind, p.additional_email_addresses, @@ -208,6 +214,8 @@ SELECT p.identity_id, p.organization_id, i.email_address, + p.source, + p.state, p.full_name, p.kind, p.additional_email_addresses, @@ -257,30 +265,35 @@ func (p *MembershipProfiles) LoadByOrganizationID( q := ` WITH profiles AS ( SELECT - id, - identity_id, - organization_id, - full_name, - kind, - additional_email_addresses, - position, - contract_start_date, - contract_end_date, - created_at, - updated_at + p.id, + p.identity_id, + p.organization_id, + i.email_address, + p.source, + p.state, + p.full_name, + p.kind, + p.additional_email_addresses, + p.position, + p.contract_start_date, + p.contract_end_date, + p.created_at, + p.updated_at FROM - iam_membership_profiles + iam_membership_profiles p + INNER JOIN identities i ON i.id = p.identity_id WHERE - %s - AND organization_id = @organization_id - AND %s + p.%s + AND p.organization_id = @organization_id AND %s ) SELECT p.id, p.identity_id, p.organization_id, - i.email_address, + p.email_address, + p.source, + p.state, p.full_name, p.kind, p.additional_email_addresses, @@ -291,7 +304,8 @@ SELECT p.created_at, p.updated_at FROM profiles p -INNER JOIN identities i ON i.id = p.identity_id +WHERE + %s ` q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) @@ -317,7 +331,6 @@ INNER JOIN identities i ON i.id = p.identity_id func (p *MembershipProfiles) LoadByIdentityID( ctx context.Context, conn pg.Conn, - scope Scoper, identityID gid.GID, cursor *page.Cursor[MembershipProfileOrderField], filter *MembershipProfileFilter, @@ -325,29 +338,34 @@ func (p *MembershipProfiles) LoadByIdentityID( q := ` WITH profiles AS ( SELECT - id, - identity_id, - organization_id, - full_name, - kind, - additional_email_addresses, - position, - contract_start_date, - contract_end_date, - created_at, - updated_at + p.id, + p.identity_id, + p.organization_id, + i.email_address, + p.source, + p.state, + p.full_name, + p.kind, + p.additional_email_addresses, + p.position, + p.contract_start_date, + p.contract_end_date, + p.created_at, + p.updated_at FROM - iam_membership_profiles + iam_membership_profiles p + INNER JOIN identities i ON i.id = p.identity_id WHERE - %s - AND identity_id = @identity_id + p.identity_id = @identity_id AND %s ) SELECT p.id, p.identity_id, p.organization_id, - i.email_address, + p.email_address, + p.source, + p.state, p.full_name, p.kind, p.additional_email_addresses, @@ -358,16 +376,14 @@ SELECT p.created_at, p.updated_at FROM profiles p -INNER JOIN identities i ON i.id = p.identity_id INNER JOIN organizations o ON o.id = p.organization_id WHERE %s ` - q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) + q = fmt.Sprintf(q, filter.SQLFragment(), cursor.SQLFragment()) args := pgx.NamedArgs{"identity_id": identityID} - maps.Copy(args, scope.SQLArguments()) rows, err := conn.Query(ctx, q, args) if err != nil { @@ -600,6 +616,8 @@ WITH attendees AS ( p.identity_id, p.organization_id, i.email_address, + p.source, + p.state, p.full_name, p.kind, p.additional_email_addresses, @@ -624,6 +642,8 @@ SELECT organization_id, kind, email_address, + source, + state, full_name, additional_email_addresses, position, @@ -684,6 +704,8 @@ SELECT p.kind, p.full_name, i.email_address, + p.source, + p.state, p.additional_email_addresses, p.position, p.contract_start_date, @@ -725,10 +747,11 @@ func (p *MembershipProfiles) CountByIdentityID( SELECT COUNT(*) FROM - iam_membership_profiles + iam_membership_profiles p +INNER JOIN identities i ON i.id = p.identity_id WHERE %s - AND identity_id = @identity_id + AND p.identity_id = @identity_id ` q = fmt.Sprintf(q, filter.SQLFragment()) @@ -758,11 +781,12 @@ func (p *MembershipProfiles) CountByOrganizationID( SELECT COUNT(*) FROM - iam_membership_profiles + iam_membership_profiles p +INNER JOIN identities i ON i.id = p.identity_id WHERE - %s + p.%s AND %s - AND organization_id = @organization_id + AND p.organization_id = @organization_id ` q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment()) @@ -782,6 +806,45 @@ WHERE return count, nil } +func (p *MembershipProfiles) CountActiveOwnerByOrganizationID( + ctx context.Context, + conn pg.Conn, + scope Scoper, + organizationID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(*) +FROM + iam_membership_profiles p +INNER JOIN iam_memberships m ON m.identity_id = p.identity_id AND m.organization_id = p.organization_id +WHERE + %s + AND p.organization_id = @organization_id + AND p.state = @state + AND m.role = @role +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "state": ProfileStateActive, + "role": MembershipRoleOwner, + "organization_id": organizationID, + } + maps.Copy(args, scope.SQLArguments()) + + row := conn.QueryRow(ctx, q, args) + + var count int + err := row.Scan(&count) + if err != nil { + return 0, fmt.Errorf("cannot collect count: %w", err) + } + + return count, nil +} + func (p *MembershipProfile) Insert( ctx context.Context, conn pg.Conn, @@ -793,6 +856,8 @@ INSERT INTO id, identity_id, organization_id, + source, + state, full_name, kind, additional_email_addresses, @@ -807,6 +872,8 @@ VALUES ( @id, @identity_id, @organization_id, + @source, + @state, @full_name, @kind, COALESCE(@additional_email_addresses, '{}'::CITEXT[]), @@ -823,6 +890,8 @@ VALUES ( "id": p.ID, "identity_id": p.IdentityID, "organization_id": p.OrganizationID, + "source": p.Source, + "state": p.State, "full_name": p.FullName, "kind": p.Kind, "additional_email_addresses": p.AdditionalEmailAddresses, @@ -850,6 +919,8 @@ func (p *MembershipProfile) Update( UPDATE iam_membership_profiles SET + source = @source, + state = @state, full_name = @full_name, kind = @kind, additional_email_addresses = @additional_email_addresses, @@ -866,6 +937,8 @@ WHERE args := pgx.StrictNamedArgs{ "id": p.ID, + "source": p.Source, + "state": p.State, "full_name": p.FullName, "kind": p.Kind, "additional_email_addresses": p.AdditionalEmailAddresses, @@ -888,6 +961,38 @@ WHERE return nil } +func (p *MembershipProfiles) ResetSCIMSources( + ctx context.Context, + conn pg.Conn, + scope Scoper, + organizationID gid.GID, +) error { + q := ` +UPDATE iam_membership_profiles +SET + source = 'MANUAL', + updated_at = @updated_at +WHERE + %s + AND organization_id = @organization_id + AND source = 'SCIM' +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{ + "organization_id": organizationID, + "updated_at": time.Now(), + } + maps.Copy(args, scope.SQLArguments()) + + _, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot reset SCIM user sources: %w", err) + } + + return nil +} + func (p *MembershipProfile) Delete( ctx context.Context, conn pg.Conn, diff --git a/pkg/coredata/membership_profile_filter.go b/pkg/coredata/membership_profile_filter.go index 4b56009c4..05bde5a5a 100644 --- a/pkg/coredata/membership_profile_filter.go +++ b/pkg/coredata/membership_profile_filter.go @@ -18,12 +18,16 @@ import ( "time" "github.com/jackc/pgx/v5" + "go.probo.inc/probo/pkg/mail" ) type ( MembershipProfileFilter struct { excludeContractEnded *bool currentDate time.Time + email *mail.Addr + state *ProfileState + source *ProfileSource } ) @@ -34,20 +38,72 @@ func NewMembershipProfileFilter(excludeContractEnded *bool) *MembershipProfileFi } } +func (f *MembershipProfileFilter) WithEmail(email *mail.Addr) *MembershipProfileFilter { + f.email = email + return f +} + +func (f *MembershipProfileFilter) Email() *mail.Addr { + return f.email +} + +func (f *MembershipProfileFilter) WithState(state ProfileState) *MembershipProfileFilter { + f.state = &state + return f +} + +func (f *MembershipProfileFilter) State() *ProfileState { + return f.state +} + +func (f *MembershipProfileFilter) WithSource(source ProfileSource) *MembershipProfileFilter { + f.source = &source + return f +} + +func (f *MembershipProfileFilter) Source() *ProfileSource { + return f.source +} + func (f *MembershipProfileFilter) SQLArguments() pgx.StrictNamedArgs { return pgx.StrictNamedArgs{ + "filter_email": f.email, "exclude_contract_ended": f.excludeContractEnded, "current_date": f.currentDate, + "filter_state": f.state, + "filter_source": f.source, } } func (f *MembershipProfileFilter) SQLFragment() string { return ` ( + CASE + WHEN @filter_email::text IS NOT NULL THEN + i.email_address = @filter_email::text + ELSE TRUE + END +) +AND ( CASE WHEN @exclude_contract_ended::boolean IS NOT NULL AND @exclude_contract_ended::boolean = true THEN - (contract_end_date IS NULL OR contract_end_date >= @current_date::date) + (p.contract_end_date IS NULL OR p.contract_end_date >= @current_date::date) ELSE TRUE END -)` +) +AND ( + CASE + WHEN @filter_state::text IS NOT NULL THEN + p.state = @filter_state::membership_state + ELSE TRUE + END +) +AND ( + CASE + WHEN @filter_source::text IS NOT NULL THEN + p.source = @filter_source::text + ELSE TRUE + END +) +` } diff --git a/pkg/coredata/migrations/20260212T163603Z.sql b/pkg/coredata/migrations/20260212T163603Z.sql index 08f7a3f75..9c8413b86 100644 --- a/pkg/coredata/migrations/20260212T163603Z.sql +++ b/pkg/coredata/migrations/20260212T163603Z.sql @@ -1,2 +1,46 @@ +ALTER TABLE + iam_membership_profiles +ADD + COLUMN state membership_state NOT NULL DEFAULT 'ACTIVE', +ADD + COLUMN source TEXT NOT NULL DEFAULT 'MANUAL'; + +UPDATE + iam_membership_profiles p +SET + state = m.state, + source = m.source +FROM + iam_memberships m +WHERE + m.id = p.membership_id; + ALTER TABLE iam_membership_profiles DROP COLUMN membership_id; + +ALTER TABLE + iam_scim_events +ADD + COLUMN user_name CITEXT NOT NULL DEFAULT ''; + +WITH emails AS ( + SELECT + i.email_address, + m.id + FROM + iam_memberships m + INNER JOIN identities i ON i.id = m.identity_id +) +UPDATE + iam_scim_events se +SET + user_name = e.email_address +FROM + emails e +WHERE + e.id = se.membership_id; + +ALTER TABLE + iam_scim_events +ALTER COLUMN + user_name DROP DEFAULT; diff --git a/pkg/coredata/membership_source.go b/pkg/coredata/profile_source.go similarity index 66% rename from pkg/coredata/membership_source.go rename to pkg/coredata/profile_source.go index a624a693a..bf278d12f 100644 --- a/pkg/coredata/membership_source.go +++ b/pkg/coredata/profile_source.go @@ -19,19 +19,19 @@ import ( "fmt" ) -type MembershipSource string +type ProfileSource string const ( - MembershipSourceManual MembershipSource = "MANUAL" - MembershipSourceSAML MembershipSource = "SAML" - MembershipSourceSCIM MembershipSource = "SCIM" + ProfileSourceManual ProfileSource = "MANUAL" + ProfileSourceSAML ProfileSource = "SAML" + ProfileSourceSCIM ProfileSource = "SCIM" ) -func (s MembershipSource) String() string { +func (s ProfileSource) String() string { return string(s) } -func (s *MembershipSource) Scan(value any) error { +func (s *ProfileSource) Scan(value any) error { var str string switch v := value.(type) { case string: @@ -39,22 +39,22 @@ func (s *MembershipSource) Scan(value any) error { case []byte: str = string(v) default: - return fmt.Errorf("unsupported type for MembershipSource: %T", value) + return fmt.Errorf("unsupported type for ProfileSource: %T", value) } switch str { case "MANUAL": - *s = MembershipSourceManual + *s = ProfileSourceManual case "SAML": - *s = MembershipSourceSAML + *s = ProfileSourceSAML case "SCIM": - *s = MembershipSourceSCIM + *s = ProfileSourceSCIM default: - return fmt.Errorf("invalid MembershipSource value: %q", str) + return fmt.Errorf("invalid ProfileSource value: %q", str) } return nil } -func (s MembershipSource) Value() (driver.Value, error) { +func (s ProfileSource) Value() (driver.Value, error) { return s.String(), nil } diff --git a/pkg/coredata/membership_state.go b/pkg/coredata/profile_state.go similarity index 69% rename from pkg/coredata/membership_state.go rename to pkg/coredata/profile_state.go index 657b1c5f0..bd0ee256a 100644 --- a/pkg/coredata/membership_state.go +++ b/pkg/coredata/profile_state.go @@ -19,18 +19,18 @@ import ( "fmt" ) -type MembershipState string +type ProfileState string const ( - MembershipStateActive MembershipState = "ACTIVE" - MembershipStateInactive MembershipState = "INACTIVE" + ProfileStateActive ProfileState = "ACTIVE" + ProfileStateInactive ProfileState = "INACTIVE" ) -func (s MembershipState) String() string { +func (s ProfileState) String() string { return string(s) } -func (s *MembershipState) Scan(value any) error { +func (s *ProfileState) Scan(value any) error { var str string switch v := value.(type) { case string: @@ -38,20 +38,20 @@ func (s *MembershipState) Scan(value any) error { case []byte: str = string(v) default: - return fmt.Errorf("unsupported type for MembershipState: %T", value) + return fmt.Errorf("unsupported type for ProfileState: %T", value) } switch str { case "ACTIVE": - *s = MembershipStateActive + *s = ProfileStateActive case "INACTIVE": - *s = MembershipStateInactive + *s = ProfileStateInactive default: - return fmt.Errorf("invalid MembershipState value: %q", str) + return fmt.Errorf("invalid ProfileState value: %q", str) } return nil } -func (s MembershipState) Value() (driver.Value, error) { +func (s ProfileState) Value() (driver.Value, error) { return s.String(), nil } diff --git a/pkg/coredata/scim_event.go b/pkg/coredata/scim_event.go index 0550ba4b3..6c0a8fd18 100644 --- a/pkg/coredata/scim_event.go +++ b/pkg/coredata/scim_event.go @@ -39,7 +39,7 @@ type ( ResponseBody *string `db:"response_body"` StatusCode int `db:"status_code"` ErrorMessage *string `db:"error_message"` - MembershipID *gid.GID `db:"membership_id"` + UserName string `db:"user_name"` IPAddress net.IP `db:"ip_address"` CreatedAt time.Time `db:"created_at"` } @@ -87,7 +87,7 @@ SELECT response_body, status_code, error_message, - membership_id, + user_name, ip_address, created_at FROM @@ -139,7 +139,7 @@ INSERT INTO iam_scim_events ( response_body, status_code, error_message, - membership_id, + user_name, ip_address, created_at ) VALUES ( @@ -153,7 +153,7 @@ INSERT INTO iam_scim_events ( @response_body, @status_code, @error_message, - @membership_id, + @user_name, @ip_address, @created_at ) @@ -170,7 +170,7 @@ INSERT INTO iam_scim_events ( "response_body": s.ResponseBody, "status_code": s.StatusCode, "error_message": s.ErrorMessage, - "membership_id": s.MembershipID, + "user_name": s.UserName, "ip_address": s.IPAddress, "created_at": s.CreatedAt, } @@ -201,7 +201,7 @@ SELECT response_body, status_code, error_message, - membership_id, + user_name, ip_address, created_at FROM @@ -281,7 +281,7 @@ SELECT response_body, status_code, error_message, - membership_id, + user_name, ip_address, created_at FROM diff --git a/pkg/iam/account_service.go b/pkg/iam/account_service.go index 5a92dc624..01ea96915 100644 --- a/pkg/iam/account_service.go +++ b/pkg/iam/account_service.go @@ -188,6 +188,7 @@ func (s *AccountService) AcceptInvitation( ) (*coredata.Invitation, *coredata.Membership, error) { var ( now = time.Now() + profile = &coredata.MembershipProfile{} membership = &coredata.Membership{} invitation = &coredata.Invitation{} ) @@ -228,47 +229,24 @@ func (s *AccountService) AcceptInvitation( tenantID := invitation.OrganizationID.TenantID() scope := coredata.NewScope(invitation.OrganizationID.TenantID()) - existingMembership := &coredata.Membership{} - if err := existingMembership.LoadByIdentityAndOrg( + existingProfile := &coredata.MembershipProfile{} + if err := existingProfile.LoadByIdentityIDAndOrganizationID( ctx, tx, scope, identityID, invitation.OrganizationID, - ); err != nil && err != coredata.ErrResourceNotFound { - return fmt.Errorf("cannot load existing membership: %w", err) - } - - if existingMembership.ID != gid.Nil && existingMembership.State == coredata.MembershipStateInactive { - existingMembership.State = coredata.MembershipStateActive - existingMembership.Role = invitation.Role - existingMembership.UpdatedAt = now - - if err := existingMembership.Update(ctx, tx, scope); err != nil { - return fmt.Errorf("cannot reactivate membership: %w", err) + ); err != nil { + if !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot load existing profile: %w", err) } - membership = existingMembership - } else { - membership = &coredata.Membership{ - ID: gid.New(tenantID, coredata.MembershipEntityType), - IdentityID: identityID, - OrganizationID: invitation.OrganizationID, - Role: invitation.Role, - Source: coredata.MembershipSourceManual, - State: coredata.MembershipStateActive, - CreatedAt: now, - UpdatedAt: now, - } - - if err := membership.Insert(ctx, tx, scope); err != nil { - return fmt.Errorf("cannot create membership: %w", err) - } - - profile := &coredata.MembershipProfile{ + profile = &coredata.MembershipProfile{ ID: gid.New(tenantID, coredata.MembershipProfileEntityType), IdentityID: identity.ID, OrganizationID: invitation.OrganizationID, + Source: coredata.ProfileSourceManual, + State: coredata.ProfileStateActive, FullName: identity.FullName, CreatedAt: now, UpdatedAt: now, @@ -277,6 +255,51 @@ func (s *AccountService) AcceptInvitation( if err := profile.Insert(ctx, tx); err != nil { return fmt.Errorf("cannot insert profile: %w", err) } + } else { + if existingProfile.State == coredata.ProfileStateInactive { + existingProfile.State = coredata.ProfileStateActive + + if err := existingProfile.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot reactivate profile: %w", err) + } + } + + profile = existingProfile + } + + existingMembership := &coredata.Membership{} + if err := existingMembership.LoadByIdentityAndOrg( + ctx, + tx, + scope, + identityID, + invitation.OrganizationID, + ); err != nil { + if !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot load existing membership: %w", err) + } + + membership = &coredata.Membership{ + ID: gid.New(tenantID, coredata.MembershipEntityType), + IdentityID: identityID, + OrganizationID: invitation.OrganizationID, + Role: invitation.Role, + CreatedAt: now, + UpdatedAt: now, + } + + if err := membership.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot create membership: %w", err) + } + } else { + existingMembership.Role = invitation.Role + existingMembership.UpdatedAt = now + + if err := existingMembership.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot assign membership role: %w", err) + } + + membership = existingMembership } invitation.AcceptedAt = &now @@ -384,32 +407,6 @@ func (s *AccountService) CountPendingInvitations( return count, nil } -func (s *AccountService) CountMemberships( - ctx context.Context, - identityID gid.GID, -) (int, error) { - var count int - - err := s.pg.WithConn( - ctx, - func(conn pg.Conn) (err error) { - memberships := coredata.Memberships{} - count, err = memberships.CountByIdentityID(ctx, conn, identityID) - if err != nil { - return fmt.Errorf("cannot count memberships: %w", err) - } - - return nil - }, - ) - - if err != nil { - return 0, err - } - - return count, nil -} - func (s AccountService) ChangePassword(ctx context.Context, identityID gid.GID, req *ChangePasswordRequest) error { if err := req.Validate(); err != nil { return fmt.Errorf("invalid request: %w", err) @@ -857,7 +854,7 @@ func (s *AccountService) ListProfilesForIdentity( err := s.pg.WithConn( ctx, func(conn pg.Conn) error { - if err := profiles.LoadByIdentityID(ctx, conn, coredata.NewNoScope(), identityID, cursor, filter); err != nil { + if err := profiles.LoadByIdentityID(ctx, conn, identityID, cursor, filter); err != nil { return fmt.Errorf("cannot load profiles: %w", err) } diff --git a/pkg/iam/authorizer.go b/pkg/iam/authorizer.go index 60bd2525a..370281967 100644 --- a/pkg/iam/authorizer.go +++ b/pkg/iam/authorizer.go @@ -81,11 +81,10 @@ func (a *Authorizer) authorize(ctx context.Context, conn pg.Conn, params Authori resourceOrgID := resourceAttrs["organization_id"] // Find role for resource's organization - memberships, err := a.loadMemberships(ctx, conn, params.Principal) + membership, err := a.loadMembership(ctx, conn, params.Principal, resourceOrgID) if err != nil { return fmt.Errorf("cannot load memberships for principal: %w", err) } - membership := findMembershipForOrg(memberships, resourceOrgID) // Check whether the viewer is currently assuming the org of the accessed resource if membership != nil && params.Session != nil { @@ -144,12 +143,31 @@ func (a *Authorizer) authorize(ctx context.Context, conn pg.Conn, params Authori return NewInsufficientPermissionsError(params.Principal, params.Resource, params.Action) } -func (a *Authorizer) loadMemberships(ctx context.Context, conn pg.Conn, principalID gid.GID) (coredata.Memberships, error) { - var memberships coredata.Memberships - if err := memberships.LoadAllByIdentityID(ctx, conn, principalID); err != nil { - return nil, fmt.Errorf("cannot load memberships: %w", err) +func (a *Authorizer) loadMembership( + ctx context.Context, + conn pg.Conn, + principalID gid.GID, + resourceOrgID string, +) (*coredata.Membership, error) { + if resourceOrgID == "" { + return nil, nil } - return memberships, nil + + orgID, err := gid.ParseGID(resourceOrgID) + if err != nil { + return nil, fmt.Errorf("cannot parse gid: %w", err) + } + + membership := &coredata.Membership{} + if err := membership.LoadActiveByIdentityIDAndOrganizationID(ctx, conn, principalID, orgID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, nil + } + + return nil, fmt.Errorf("cannot load active membership: %w", err) + } + + return membership, nil } func (a *Authorizer) getActiveChildSessionForMembership( @@ -240,13 +258,3 @@ func (a *Authorizer) buildPoliciesForRole(role string) []*policy.Policy { return policies } - -func findMembershipForOrg(memberships coredata.Memberships, orgID string) *coredata.Membership { - for _, m := range memberships { - if m.OrganizationID.String() == orgID && m.State == coredata.MembershipStateActive { - return m - } - } - - return nil -} diff --git a/pkg/iam/errors.go b/pkg/iam/errors.go index 1398dfa9f..34e853d2e 100644 --- a/pkg/iam/errors.go +++ b/pkg/iam/errors.go @@ -123,28 +123,28 @@ func (e ErrMembershipNotFound) Error() string { return fmt.Sprintf("membership %q not found", e.MembershipID) } -type ErrMembershipInactive struct { - MembershipID gid.GID +type ErrUserInactive struct { + ProfileID gid.GID } -func NewMembershipInactiveError(membershipID gid.GID) error { - return &ErrMembershipInactive{MembershipID: membershipID} +func NewUserInactiveError(profileID gid.GID) error { + return &ErrUserInactive{ProfileID: profileID} } -func (e ErrMembershipInactive) Error() string { - return fmt.Sprintf("membership %q is inactive", e.MembershipID) +func (e ErrUserInactive) Error() string { + return fmt.Sprintf("user %q is inactive", e.ProfileID) } -type ErrMembershipManagedBySCIM struct { - MembershipID gid.GID +type ErrUserManagedBySCIM struct { + ProfileID gid.GID } -func NewMembershipManagedBySCIMError(membershipID gid.GID) error { - return &ErrMembershipManagedBySCIM{MembershipID: membershipID} +func NewUserManagedBySCIMError(profileID gid.GID) error { + return &ErrUserManagedBySCIM{ProfileID: profileID} } -func (e ErrMembershipManagedBySCIM) Error() string { - return fmt.Sprintf("membership %q is managed by SCIM and cannot be deleted manually", e.MembershipID) +func (e ErrUserManagedBySCIM) Error() string { + return fmt.Sprintf("user %q is managed by SCIM and cannot be deleted manually", e.ProfileID) } type ErrLastActiveOwner struct { @@ -156,7 +156,7 @@ func NewLastActiveOwnerError(membershipID gid.GID) error { } func (e ErrLastActiveOwner) Error() string { - return fmt.Sprintf("cannot remove membership %q: last active owner of the organization", e.MembershipID) + return fmt.Sprintf("cannot remove profile %q: last active owner of the organization", e.MembershipID) } type ErrOrganizationNotFound struct{ OrganizationID gid.GID } @@ -220,17 +220,17 @@ func (e ErrSessionExpired) Error() string { return fmt.Sprintf("session %q expired", e.SessionID) } -type ErrMembershipAlreadyExists struct { +type ErrUserAlreadyExists struct { IdentityID gid.GID OrganizationID gid.GID } -func NewMembershipAlreadyExistsError(identityID gid.GID, organizationID gid.GID) error { - return &ErrMembershipAlreadyExists{IdentityID: identityID, OrganizationID: organizationID} +func NewUserAlreadyExistsError(identityID gid.GID, organizationID gid.GID) error { + return &ErrUserAlreadyExists{IdentityID: identityID, OrganizationID: organizationID} } -func (e ErrMembershipAlreadyExists) Error() string { - return fmt.Sprintf("membership already exists for identity %q in organization %q", e.IdentityID, e.OrganizationID) +func (e ErrUserAlreadyExists) Error() string { + return fmt.Sprintf("user already exists for identity %q in organization %q", e.IdentityID, e.OrganizationID) } type ErrSAMLConfigurationNotFound struct{ ConfigID gid.GID } diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index a93add892..77a536575 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -212,33 +212,6 @@ func NewOrganizationService(svc *Service) *OrganizationService { return &OrganizationService{Service: svc} } -func (s *OrganizationService) CountMemberships( - ctx context.Context, - organizationID gid.GID, -) (int, error) { - var count int - scope := coredata.NewScopeFromObjectID(organizationID) - - err := s.pg.WithConn( - ctx, - func(conn pg.Conn) (err error) { - memberships := coredata.Memberships{} - count, err = memberships.CountByOrganizationID(ctx, conn, scope, organizationID, coredata.NewMembershipFilter()) - if err != nil { - return fmt.Errorf("cannot count memberships: %w", err) - } - - return nil - }, - ) - - if err != nil { - return 0, err - } - - return count, nil -} - func (s *OrganizationService) UpdateMempership( ctx context.Context, organizationID gid.GID, @@ -282,48 +255,53 @@ func (s *OrganizationService) UpdateMempership( func (s *OrganizationService) RemoveMember( ctx context.Context, organizationID gid.GID, - membershipID gid.GID, + profileID gid.GID, ) error { scope := coredata.NewScopeFromObjectID(organizationID) return s.pg.WithTx( ctx, func(tx pg.Conn) error { - membership := coredata.Membership{} + profile := coredata.MembershipProfile{} - if err := membership.LoadByID(ctx, tx, scope, membershipID); err != nil { + if err := profile.LoadByID(ctx, tx, scope, profileID); err != nil { if err == coredata.ErrResourceNotFound { - return NewMembershipNotFoundError(membershipID) + return NewProfileNotFoundError(profileID) } + return fmt.Errorf("cannot load profile: %w", err) + } + + if profile.OrganizationID != organizationID { + return NewMembershipNotFoundError(profile.ID) + } + + if profile.Source == coredata.ProfileSourceSCIM { + return NewUserManagedBySCIMError(profileID) + } + + membership := &coredata.Membership{} + if err := membership.LoadByIdentityAndOrg(ctx, tx, scope, profile.IdentityID, profile.OrganizationID); err != nil { return fmt.Errorf("cannot load membership: %w", err) } - if membership.OrganizationID != organizationID { - return NewMembershipNotFoundError(membership.ID) - } - - if membership.Source == coredata.MembershipSourceSCIM { - return NewMembershipManagedBySCIMError(membershipID) - } - - if membership.Role == coredata.MembershipRoleOwner && membership.State == coredata.MembershipStateActive { - memberships := coredata.Memberships{} - filter := coredata.NewMembershipFilter(). - WithRole(coredata.MembershipRoleOwner). - WithState(coredata.MembershipStateActive) - count, err := memberships.CountByOrganizationID(ctx, tx, scope, organizationID, filter) + if membership.Role == coredata.MembershipRoleOwner && profile.State == coredata.ProfileStateActive { + profiles := coredata.MembershipProfiles{} + count, err := profiles.CountActiveOwnerByOrganizationID(ctx, tx, scope, organizationID) if err != nil { return fmt.Errorf("cannot count active owners: %w", err) } if count <= 1 { - return NewLastActiveOwnerError(membershipID) + return NewLastActiveOwnerError(profileID) } } - err := membership.Delete(ctx, tx, scope, membershipID) - if err != nil { + if err := profile.Delete(ctx, tx, scope, profileID); err != nil { + return fmt.Errorf("cannot delete profile: %w", err) + } + + if err := membership.Delete(ctx, tx, scope, membership.ID); err != nil { return fmt.Errorf("cannot delete membership: %w", err) } @@ -466,14 +444,14 @@ func (s *OrganizationService) InviteMember( identityExists := identity.ID != gid.Nil if identityExists { - membership := &coredata.Membership{} - err = membership.LoadByIdentityAndOrg(ctx, tx, scope, identity.ID, organizationID) + profile := &coredata.MembershipProfile{} + err = profile.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, identity.ID, organizationID) if err != nil && err != coredata.ErrResourceNotFound { - return fmt.Errorf("cannot load membership: %w", err) + return fmt.Errorf("cannot load profile: %w", err) } - if membership.ID != gid.Nil && membership.State == coredata.MembershipStateActive { - return NewMembershipAlreadyExistsError(identity.ID, organizationID) + if profile.ID != gid.Nil && profile.State == coredata.ProfileStateActive { + return NewUserAlreadyExistsError(identity.ID, organizationID) } } @@ -549,13 +527,22 @@ func (s *OrganizationService) CreateOrganization( UpdatedAt: now, } + profile = &coredata.MembershipProfile{ + ID: gid.New(tenantID, coredata.MembershipProfileEntityType), + IdentityID: identityID, + OrganizationID: organization.ID, + Source: coredata.ProfileSourceManual, + State: coredata.ProfileStateActive, + FullName: req.Name, + CreatedAt: now, + UpdatedAt: now, + } + membership = &coredata.Membership{ ID: gid.New(tenantID, coredata.MembershipEntityType), IdentityID: identityID, OrganizationID: organizationID, Role: coredata.MembershipRoleOwner, - Source: coredata.MembershipSourceManual, - State: coredata.MembershipStateActive, CreatedAt: now, UpdatedAt: now, } @@ -688,25 +675,16 @@ func (s *OrganizationService) CreateOrganization( organization.HorizontalLogoFileID = &horizontalLogoFile.ID } - err = membership.Insert(ctx, tx, scope) - if err != nil { - return fmt.Errorf("cannot create membership: %w", err) - } - - profile := &coredata.MembershipProfile{ - ID: gid.New(tenantID, coredata.MembershipProfileEntityType), - IdentityID: identity.ID, - OrganizationID: organization.ID, - FullName: identity.FullName, - CreatedAt: now, - UpdatedAt: now, - } - err = profile.Insert(ctx, tx) if err != nil { return fmt.Errorf("cannot insert profile: %w", err) } + err = membership.Insert(ctx, tx, scope) + if err != nil { + return fmt.Errorf("cannot insert membership: %w", err) + } + if err := organizationContext.Insert(ctx, tx, scope); err != nil { return fmt.Errorf("cannot insert organization context: %w", err) } @@ -1508,10 +1486,10 @@ func (s OrganizationService) DeleteSCIMConfiguration( return scim.NewSCIMConfigurationNotFoundError(configID) } - memberships := &coredata.Memberships{} - err = memberships.ResetSCIMSources(ctx, tx, scope, config.OrganizationID) + profiles := &coredata.MembershipProfiles{} + err = profiles.ResetSCIMSources(ctx, tx, scope, config.OrganizationID) if err != nil { - return fmt.Errorf("cannot reset membership sources: %w", err) + return fmt.Errorf("cannot reset user sources: %w", err) } // Delete SCIM bridge and its connector if they exist diff --git a/pkg/iam/saml/errors.go b/pkg/iam/saml/errors.go index ee399ffc5..ed255381b 100644 --- a/pkg/iam/saml/errors.go +++ b/pkg/iam/saml/errors.go @@ -89,12 +89,12 @@ func (e ErrSAMLAutoSignupDisabled) Error() string { return fmt.Sprintf("SAML auto-signup is disabled for configuration %q", e.ConfigID) } -type ErrMembershipInactive struct{ MembershipID gid.GID } +type ErrUserInactive struct{ ProfileID gid.GID } -func NewMembershipInactiveError(membershipID gid.GID) error { - return &ErrMembershipInactive{MembershipID: membershipID} +func NewUserInactiveError(profileID gid.GID) error { + return &ErrUserInactive{ProfileID: profileID} } -func (e ErrMembershipInactive) Error() string { - return fmt.Sprintf("membership %q is inactive", e.MembershipID) +func (e ErrUserInactive) Error() string { + return fmt.Sprintf("user %q is inactive", e.ProfileID) } diff --git a/pkg/iam/saml/service.go b/pkg/iam/saml/service.go index c5cd39526..3cb555c19 100644 --- a/pkg/iam/saml/service.go +++ b/pkg/iam/saml/service.go @@ -296,16 +296,23 @@ func (s *Service) HandleAssertion( scope := coredata.NewScopeFromObjectID(config.OrganizationID) - err = profile.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, identity.ID, config.OrganizationID) - if err != nil && err != coredata.ErrResourceNotFound { - return fmt.Errorf("cannot load profile: %w", err) - } + if err := profile.LoadByIdentityIDAndOrganizationID( + ctx, + tx, + scope, + identity.ID, + config.OrganizationID, + ); err != nil { + if !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot load profile: %w", err) + } - if profile.ID == gid.Nil { profile = &coredata.MembershipProfile{ ID: gid.New(membership.ID.TenantID(), coredata.MembershipProfileEntityType), IdentityID: identity.ID, OrganizationID: config.OrganizationID, + Source: coredata.ProfileSourceSAML, + State: coredata.ProfileStateActive, FullName: fullname, CreatedAt: now, UpdatedAt: now, @@ -315,25 +322,28 @@ func (s *Service) HandleAssertion( if err != nil { return fmt.Errorf("cannot insert membership profile: %w", err) } + } else { + if profile.State == coredata.ProfileStateInactive { + return NewUserInactiveError(membership.ID) + } } - err = membership.LoadByIdentityAndOrg(ctx, tx, scope, identity.ID, config.OrganizationID) - if err != nil && err != coredata.ErrResourceNotFound { - return fmt.Errorf("cannot load membership: %w", err) - } + if err := membership.LoadByIdentityAndOrg( + ctx, + tx, + scope, + identity.ID, + config.OrganizationID, + ); err != nil { + if !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot load membership: %w", err) + } - if membership.ID != gid.Nil && membership.State == coredata.MembershipStateInactive { - return NewMembershipInactiveError(membership.ID) - } - - if membership.ID == gid.Nil { membership = &coredata.Membership{ ID: gid.New(config.ID.TenantID(), coredata.MembershipEntityType), IdentityID: identity.ID, OrganizationID: config.OrganizationID, Role: coredata.MembershipRoleEmployee, - Source: coredata.MembershipSourceSAML, - State: coredata.MembershipStateActive, CreatedAt: now, UpdatedAt: now, } @@ -358,34 +368,26 @@ func (s *Service) HandleAssertion( } } - if membership.Source != coredata.MembershipSourceSCIM { - needsUpdate := false + if profile.Source != coredata.ProfileSourceSCIM { + profile.FullName = fullname + profile.UpdatedAt = now + if profile.Source == coredata.ProfileSourceManual { + profile.Source = coredata.ProfileSourceSAML + } + err = profile.Update(ctx, tx, scope) + if err != nil { + return fmt.Errorf("cannot update profile: %w", err) + } if role != nil { membership.Role = *role membership.UpdatedAt = now - needsUpdate = true - } - if membership.Source == coredata.MembershipSourceManual { - membership.Source = coredata.MembershipSourceSAML - membership.UpdatedAt = now - needsUpdate = true - } - - if needsUpdate { err = membership.Update(ctx, tx, scope) if err != nil { return fmt.Errorf("cannot update membership: %w", err) } } - - profile.FullName = fullname - profile.UpdatedAt = now - err = profile.Update(ctx, tx, scope) - if err != nil { - return fmt.Errorf("cannot update profile: %w", err) - } } return nil diff --git a/pkg/iam/scim/filter.go b/pkg/iam/scim/filter.go index 47a3e73e5..7cdbf9390 100644 --- a/pkg/iam/scim/filter.go +++ b/pkg/iam/scim/filter.go @@ -24,8 +24,8 @@ import ( "go.probo.inc/probo/pkg/mail" ) -func ParseUserFilter(expr scimfilter.Expression) (*coredata.MembershipFilter, error) { - filter := coredata.NewMembershipFilter() +func ParseUserFilter(expr scimfilter.Expression) (*coredata.MembershipProfileFilter, error) { + filter := coredata.NewMembershipProfileFilter(nil) if expr == nil { return filter, nil diff --git a/pkg/iam/scim/service.go b/pkg/iam/scim/service.go index 6f7a0cea7..7c4ab08ed 100644 --- a/pkg/iam/scim/service.go +++ b/pkg/iam/scim/service.go @@ -19,6 +19,7 @@ import ( "crypto/rand" "crypto/sha256" "encoding/hex" + "errors" "fmt" "net" "strings" @@ -138,103 +139,120 @@ func (s *Service) CreateUser( } now := time.Now() - membershipState := coredata.MembershipStateActive + profileState := coredata.ProfileStateActive if !active { - membershipState = coredata.MembershipStateInactive + profileState = coredata.ProfileStateInactive } var membership *coredata.Membership + var profile *coredata.MembershipProfile scope := coredata.NewScopeFromObjectID(config.OrganizationID) err = s.pg.WithTx(ctx, func(tx pg.Conn) error { // Check if identity exists identity := &coredata.Identity{} - err := identity.LoadByEmail(ctx, tx, emailAddr) + if err := identity.LoadByEmail(ctx, tx, emailAddr); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + // Create new identity + identity = &coredata.Identity{ + ID: gid.New(gid.NilTenant, coredata.IdentityEntityType), + EmailAddress: emailAddr, + FullName: fullName, + HashedPassword: nil, + EmailAddressVerified: false, + CreatedAt: now, + UpdatedAt: now, + } - if err == coredata.ErrResourceNotFound { - // Create new identity - identity = &coredata.Identity{ - ID: gid.New(gid.NilTenant, coredata.IdentityEntityType), - EmailAddress: emailAddr, - FullName: fullName, - HashedPassword: nil, - EmailAddressVerified: false, - CreatedAt: now, - UpdatedAt: now, + err = identity.Insert(ctx, tx) + if err != nil { + return fmt.Errorf("cannot insert identity: %w", err) + } + } else { + return fmt.Errorf("cannot load identity: %w", err) } + } - err = identity.Insert(ctx, tx) - if err != nil { - return fmt.Errorf("cannot insert identity: %w", err) + // Check if profile exists + profile = &coredata.MembershipProfile{} + if err := profile.LoadByIdentityIDAndOrganizationID( + ctx, + tx, + coredata.NewScopeFromObjectID(config.OrganizationID), + identity.ID, + config.OrganizationID, + ); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + profile = &coredata.MembershipProfile{ + ID: gid.New(config.OrganizationID.TenantID(), coredata.MembershipProfileEntityType), + IdentityID: identity.ID, + OrganizationID: config.OrganizationID, + Source: coredata.ProfileSourceSCIM, + State: profileState, + FullName: fullName, + CreatedAt: now, + UpdatedAt: now, + } + + err = profile.Insert(ctx, tx) + if err != nil { + return fmt.Errorf("cannot insert profile: %w", err) + } + } else { + return fmt.Errorf("cannot load profile: %w", err) + } + } else { + profile.Source = coredata.ProfileSourceSCIM + profile.State = profileState + profile.UpdatedAt = now + if err := profile.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update profile: %w", err) } - } else if err != nil { - return fmt.Errorf("cannot load identity: %w", err) } // Check if membership exists membership = &coredata.Membership{} - err = membership.LoadByIdentityAndOrg(ctx, tx, scope, identity.ID, config.OrganizationID) + if err := membership.LoadByIdentityAndOrg( + ctx, + tx, + scope, + identity.ID, + config.OrganizationID, + ); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + // Create new membership + membership = &coredata.Membership{ + ID: gid.New(config.OrganizationID.TenantID(), coredata.MembershipEntityType), + IdentityID: identity.ID, + OrganizationID: config.OrganizationID, + Role: coredata.MembershipRoleEmployee, + CreatedAt: now, + UpdatedAt: now, + } - if err == coredata.ErrResourceNotFound { - // Create new membership - membership = &coredata.Membership{ - ID: gid.New(config.OrganizationID.TenantID(), coredata.MembershipEntityType), - IdentityID: identity.ID, - OrganizationID: config.OrganizationID, - Role: coredata.MembershipRoleEmployee, - Source: coredata.MembershipSourceSCIM, - State: membershipState, - CreatedAt: now, - UpdatedAt: now, - } + err = membership.Insert(ctx, tx, scope) + if err != nil { + return fmt.Errorf("cannot insert membership: %w", err) + } - err = membership.Insert(ctx, tx, scope) - if err != nil { - return fmt.Errorf("cannot insert membership: %w", err) - } + // Expire all pending invitations for email in organization + invitations := &coredata.Invitations{} + onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending}) + err := invitations.ExpireByEmailAndOrganization( + ctx, + tx, + coredata.NewScopeFromObjectID(config.OrganizationID), + emailAddr, + config.OrganizationID, + onlyPending, + ) - // Create membership profile - membershipProfile := &coredata.MembershipProfile{ - ID: gid.New(membership.ID.TenantID(), coredata.MembershipProfileEntityType), - IdentityID: identity.ID, - OrganizationID: config.OrganizationID, - FullName: fullName, - CreatedAt: now, - UpdatedAt: now, - } - - err = membershipProfile.Insert(ctx, tx) - if err != nil { - return fmt.Errorf("cannot insert membership profile: %w", err) - } - - // Expire all pending invitations for email in organization - invitations := &coredata.Invitations{} - onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending}) - err := invitations.ExpireByEmailAndOrganization( - ctx, - tx, - coredata.NewScopeFromObjectID(config.OrganizationID), - emailAddr, - config.OrganizationID, - onlyPending, - ) - - if err != nil { - return fmt.Errorf("cannot expire pending invitations by email") - } - } else if err != nil { - return fmt.Errorf("cannot load membership: %w", err) - } else { - // Update existing membership - follow what SCIM tells us - membership.Source = coredata.MembershipSourceSCIM - membership.State = membershipState - membership.UpdatedAt = now - - err = membership.Update(ctx, tx, scope) - if err != nil { - return fmt.Errorf("cannot update membership: %w", err) + if err != nil { + return fmt.Errorf("cannot expire pending invitations by email") + } + } else { + return fmt.Errorf("cannot load membership: %w", err) } } @@ -245,32 +263,45 @@ func (s *Service) CreateUser( return scim.Resource{}, err } - return membershipToResource(membership), nil + return userToResource(profile), nil } func (s *Service) GetUser( ctx context.Context, config *coredata.SCIMConfiguration, - membershipID gid.GID, + profileID gid.GID, ) (scim.Resource, error) { scope := coredata.NewScopeFromObjectID(config.OrganizationID) - var membership *coredata.Membership + var ( + profile *coredata.MembershipProfile + membership *coredata.Membership + ) err := s.pg.WithConn( ctx, func(conn pg.Conn) error { - membership = &coredata.Membership{} - err := membership.LoadByID(ctx, conn, scope, membershipID) - if err != nil { + profile = &coredata.MembershipProfile{} + if err := profile.LoadByID(ctx, conn, scope, profileID); err != nil { if err == coredata.ErrResourceNotFound { - return scimerrors.ScimErrorResourceNotFound(membershipID.String()) + return scimerrors.ScimErrorResourceNotFound(profileID.String()) } return fmt.Errorf("cannot load membership: %w", err) } - if membership.OrganizationID != config.OrganizationID { - return scimerrors.ScimErrorResourceNotFound(membershipID.String()) + if profile.OrganizationID != config.OrganizationID { + return scimerrors.ScimErrorResourceNotFound(profileID.String()) + } + + membership = &coredata.Membership{} + if err := membership.LoadByIdentityAndOrg( + ctx, + conn, + scope, + profile.IdentityID, + profile.OrganizationID, + ); err != nil { + return fmt.Errorf("cannot load membership: %w", err) } return nil @@ -281,7 +312,7 @@ func (s *Service) GetUser( return scim.Resource{}, err } - return membershipToResource(membership), nil + return userToResource(profile), nil } func (s *Service) ListUsers( @@ -301,31 +332,31 @@ func (s *Service) ListUsers( // when they don't exist in the identity provider. // 2. When a manual user exists in the identity provider but not in the // SCIM list, CreateUser is called which enrolls them into SCIM management. - filter.WithSource(coredata.MembershipSourceSCIM) + filter.WithSource(coredata.ProfileSourceSCIM) scope := coredata.NewScopeFromObjectID(config.OrganizationID) - var memberships coredata.Memberships + var profiles coredata.MembershipProfiles var totalCount int err = s.pg.WithConn( ctx, func(conn pg.Conn) error { var err error - totalCount, err = memberships.CountByOrganizationID(ctx, conn, scope, config.OrganizationID, filter) + totalCount, err = profiles.CountByOrganizationID(ctx, conn, scope, config.OrganizationID, filter) if err != nil { - return fmt.Errorf("cannot count memberships: %w", err) + return fmt.Errorf("cannot count profiles: %w", err) } - orderBy := page.OrderBy[coredata.MembershipOrderField]{ - Field: coredata.MembershipOrderFieldCreatedAt, + orderBy := page.OrderBy[coredata.MembershipProfileOrderField]{ + Field: coredata.MembershipProfileOrderFieldCreatedAt, Direction: page.OrderDirectionDesc, } cursor := page.NewCursor(count, nil, page.Head, orderBy) - err = memberships.LoadByOrganizationID(ctx, conn, scope, config.OrganizationID, cursor, filter) + err = profiles.LoadByOrganizationID(ctx, conn, scope, config.OrganizationID, cursor, filter) if err != nil { - return fmt.Errorf("cannot load memberships: %w", err) + return fmt.Errorf("cannot load profiles: %w", err) } return nil @@ -336,9 +367,9 @@ func (s *Service) ListUsers( return nil, 0, err } - resources := make([]scim.Resource, 0, len(memberships)) - for _, m := range memberships { - resources = append(resources, membershipToResource(m)) + resources := make([]scim.Resource, 0, len(profiles)) + for _, p := range profiles { + resources = append(resources, userToResource(p)) } return resources, totalCount, nil @@ -347,61 +378,95 @@ func (s *Service) ListUsers( func (s *Service) ReplaceUser( ctx context.Context, config *coredata.SCIMConfiguration, - membershipID gid.GID, + profileID gid.GID, attributes scim.ResourceAttributes, ) (scim.Resource, error) { fullName, active := ParseUserFromReplaceAttributes(attributes) - membership, err := s.updateUser(ctx, config, membershipID, fullName, active) + profile, err := s.updateUser(ctx, config, profileID, fullName, active) if err != nil { return scim.Resource{}, err } - return membershipToResource(membership), nil + return userToResource(profile), nil } func (s *Service) PatchUser( ctx context.Context, config *coredata.SCIMConfiguration, - membershipID gid.GID, + profileID gid.GID, operations []scim.PatchOperation, ) (scim.Resource, error) { fullName, active := ParseUserFromPatchOperations(operations) - membership, err := s.updateUser(ctx, config, membershipID, fullName, active) + profile, err := s.updateUser(ctx, config, profileID, fullName, active) if err != nil { return scim.Resource{}, err } - return membershipToResource(membership), nil + return userToResource(profile), nil } func (s *Service) updateUser( ctx context.Context, config *coredata.SCIMConfiguration, - membershipID gid.GID, + profileID gid.GID, fullName string, active *bool, -) (*coredata.Membership, error) { +) (*coredata.MembershipProfile, error) { scope := coredata.NewScopeFromObjectID(config.OrganizationID) now := time.Now() - var membership *coredata.Membership + var ( + membership *coredata.Membership + profile *coredata.MembershipProfile + ) err := s.pg.WithTx( ctx, func(tx pg.Conn) error { - membership = &coredata.Membership{} - if err := membership.LoadByID(ctx, tx, scope, membershipID); err != nil { - if err == coredata.ErrResourceNotFound { - return scimerrors.ScimErrorResourceNotFound(membershipID.String()) + profile = &coredata.MembershipProfile{} + if err := profile.LoadByID(ctx, tx, scope, profileID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return scimerrors.ScimErrorResourceNotFound(profileID.String()) } + + return fmt.Errorf("cannot load profile: %w", err) + } + + if profile.OrganizationID != config.OrganizationID { + return scimerrors.ScimErrorResourceNotFound(profileID.String()) + } + + membership = &coredata.Membership{} + if err := membership.LoadByIdentityAndOrg(ctx, tx, scope, profile.IdentityID, profile.OrganizationID); err != nil { return fmt.Errorf("cannot load membership: %w", err) } - if membership.OrganizationID != config.OrganizationID { - return scimerrors.ScimErrorResourceNotFound(membershipID.String()) + shouldReactivate := active != nil && *active && profile.State == coredata.ProfileStateInactive + shouldDeactivate := active != nil && !*active && profile.State == coredata.ProfileStateActive + + if fullName != "" { + profile.FullName = fullName + profile.UpdatedAt = now } - needsUpdate := false + if shouldReactivate { + profile.State = coredata.ProfileStateActive + profile.UpdatedAt = now + } else if shouldDeactivate { + profile.State = coredata.ProfileStateInactive + profile.UpdatedAt = now + } + + if profile.Source != coredata.ProfileSourceSCIM { + profile.Source = coredata.ProfileSourceSCIM + profile.UpdatedAt = now + } + + if err := profile.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update membership profile: %w", err) + } + + needsUpdate := shouldReactivate || shouldDeactivate if active != nil { identity := &coredata.Identity{} @@ -409,11 +474,8 @@ func (s *Service) updateUser( return fmt.Errorf("cannot load identity: %w", err) } - if *active && membership.State == coredata.MembershipStateInactive { - membership.State = coredata.MembershipStateActive + if shouldReactivate { membership.Role = coredata.MembershipRoleEmployee - needsUpdate = true - // Expire all pending invitations for email in organization invitations := &coredata.Invitations{} onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending}) @@ -427,10 +489,7 @@ func (s *Service) updateUser( ); err != nil { return fmt.Errorf("cannot expire pending invitations by email: %w", err) } - } else if !*active && membership.State == coredata.MembershipStateActive { - membership.State = coredata.MembershipStateInactive - needsUpdate = true - + } else if shouldDeactivate { // Expire all pending invitations for email in organization invitations := &coredata.Invitations{} onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending}) @@ -447,11 +506,6 @@ func (s *Service) updateUser( } } - if membership.Source != coredata.MembershipSourceSCIM { - membership.Source = coredata.MembershipSourceSCIM - needsUpdate = true - } - if needsUpdate { membership.UpdatedAt = now if err := membership.Update(ctx, tx, scope); err != nil { @@ -459,18 +513,6 @@ func (s *Service) updateUser( } } - profile := &coredata.MembershipProfile{} - if err := profile.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, membership.IdentityID, membership.OrganizationID); err == nil { - if fullName != "" { - profile.FullName = fullName - profile.UpdatedAt = now - - if err := profile.Update(ctx, tx, scope); err != nil { - return fmt.Errorf("cannot update membership profile: %w", err) - } - } - } - return nil }, ) @@ -479,7 +521,7 @@ func (s *Service) updateUser( return nil, err } - return membership, nil + return profile, nil } func (s *Service) DeleteUser( @@ -538,19 +580,14 @@ func (s *Service) LogEvent( config *coredata.SCIMConfiguration, method string, path string, - membershipID *gid.GID, + userName string, ipAddress net.IP, statusCode int, errorMessage *string, ) { scope := coredata.NewScopeFromObjectID(config.OrganizationID) - var mID gid.GID - if membershipID != nil { - mID = *membershipID - } - - event := s.createEvent(config, method, path, mID, ipAddress, statusCode, errorMessage) + event := s.createEvent(config, method, path, userName, ipAddress, statusCode, errorMessage) err := s.pg.WithConn( ctx, @@ -572,7 +609,7 @@ func (s *Service) createEvent( config *coredata.SCIMConfiguration, method string, path string, - membershipID gid.GID, + userName string, ipAddress net.IP, statusCode int, errorMessage *string, @@ -586,13 +623,10 @@ func (s *Service) createEvent( StatusCode: statusCode, ErrorMessage: errorMessage, IPAddress: ipAddress, + UserName: userName, CreatedAt: time.Now(), } - if membershipID != gid.Nil { - event.MembershipID = &membershipID - } - return event } @@ -730,28 +764,28 @@ func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName st return fullName, active } -func membershipToResource(m *coredata.Membership) scim.Resource { +func userToResource(p *coredata.MembershipProfile) scim.Resource { return scim.Resource{ - ID: m.ID.String(), - ExternalID: optional.NewString(m.ID.String()), + ID: p.ID.String(), + ExternalID: optional.NewString(p.ID.String()), Attributes: scim.ResourceAttributes{ - "userName": m.EmailAddress.String(), - "displayName": m.FullName, - "active": m.State == coredata.MembershipStateActive, + "userName": p.EmailAddress.String(), + "displayName": p.FullName, + "active": p.State == coredata.ProfileStateActive, "name": map[string]any{ - "formatted": m.FullName, + "formatted": p.FullName, }, "emails": []map[string]any{ { - "value": m.EmailAddress.String(), + "value": p.EmailAddress.String(), "type": "work", "primary": true, }, }, }, Meta: scim.Meta{ - Created: &m.CreatedAt, - LastModified: &m.UpdatedAt, + Created: &p.CreatedAt, + LastModified: &p.UpdatedAt, }, } } diff --git a/pkg/iam/session_service.go b/pkg/iam/session_service.go index a6e5da5d2..1fe2761f1 100644 --- a/pkg/iam/session_service.go +++ b/pkg/iam/session_service.go @@ -324,6 +324,7 @@ func (s SessionService) OpenPasswordChildSessionForOrganization( now = time.Now() rootSession = &coredata.Session{} identity = &coredata.Identity{} + profile = &coredata.MembershipProfile{} membership = &coredata.Membership{} childSession = &coredata.Session{} scope = coredata.NewScopeFromObjectID(organizationID) @@ -353,6 +354,18 @@ func (s SessionService) OpenPasswordChildSessionForOrganization( return fmt.Errorf("cannot load identity: %w", err) } + err = profile.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, rootSession.IdentityID, organizationID) + if err != nil { + if err == coredata.ErrResourceNotFound { + return NewProfileNotFoundError(organizationID) + } + return fmt.Errorf("cannot load profile: %w", err) + } + + if profile.State == coredata.ProfileStateInactive { + return NewUserInactiveError(profile.ID) + } + err = membership.LoadByIdentityInOrganization(ctx, tx, rootSession.IdentityID, organizationID) if err != nil { if err == coredata.ErrResourceNotFound { @@ -361,10 +374,6 @@ func (s SessionService) OpenPasswordChildSessionForOrganization( return fmt.Errorf("cannot load membership: %w", err) } - if membership.State == coredata.MembershipStateInactive { - return NewMembershipInactiveError(membership.ID) - } - tenantID := scope.GetTenantID() childSession = &coredata.Session{ ID: gid.New(tenantID, coredata.SessionEntityType), @@ -417,6 +426,7 @@ func (s SessionService) OpenSAMLChildSessionForOrganization( now = time.Now() rootSession = &coredata.Session{} identity = &coredata.Identity{} + profile = &coredata.MembershipProfile{} membership = &coredata.Membership{} childSession = &coredata.Session{} scope = coredata.NewScopeFromObjectID(organizationID) @@ -446,6 +456,18 @@ func (s SessionService) OpenSAMLChildSessionForOrganization( return fmt.Errorf("cannot load identity: %w", err) } + err = profile.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, rootSession.IdentityID, organizationID) + if err != nil { + if err == coredata.ErrResourceNotFound { + return NewProfileNotFoundError(organizationID) + } + return fmt.Errorf("cannot load profile: %w", err) + } + + if profile.State == coredata.ProfileStateInactive { + return NewUserInactiveError(profile.ID) + } + err = membership.LoadByIdentityInOrganization(ctx, tx, rootSession.IdentityID, organizationID) if err != nil { if err == coredata.ErrResourceNotFound { @@ -454,10 +476,6 @@ func (s SessionService) OpenSAMLChildSessionForOrganization( return fmt.Errorf("cannot load membership: %w", err) } - if membership.State == coredata.MembershipStateInactive { - return NewMembershipInactiveError(membership.ID) - } - tenantID := scope.GetTenantID() childSession = &coredata.Session{ ID: gid.New(tenantID, coredata.SessionEntityType), @@ -497,6 +515,7 @@ func (s SessionService) AssumeOrganizationSession( now = time.Now() rootSession = &coredata.Session{} identity = &coredata.Identity{} + profile = &coredata.MembershipProfile{} membership = &coredata.Membership{} childSession = &coredata.Session{} scope = coredata.NewScopeFromObjectID(organizationID) @@ -524,6 +543,17 @@ func (s SessionService) AssumeOrganizationSession( return fmt.Errorf("cannot load identity: %w", err) } + if err := profile.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, rootSession.IdentityID, organizationID); err != nil { + if err == coredata.ErrResourceNotFound { + return NewProfileNotFoundError(organizationID) + } + return fmt.Errorf("cannot load profile: %w", err) + } + + if profile.State == coredata.ProfileStateInactive { + return NewUserInactiveError(profile.ID) + } + if err := membership.LoadByIdentityInOrganization(ctx, tx, rootSession.IdentityID, organizationID); err != nil { if err == coredata.ErrResourceNotFound { return NewMembershipNotFoundError(organizationID) @@ -531,10 +561,6 @@ func (s SessionService) AssumeOrganizationSession( return fmt.Errorf("cannot load membership: %w", err) } - if membership.State == coredata.MembershipStateInactive { - return NewMembershipInactiveError(membership.ID) - } - samlConfig := &coredata.SAMLConfiguration{} err := samlConfig.LoadByOrganizationIDAndEmailDomain( ctx, diff --git a/pkg/server/api/connect/v1/schema.graphql b/pkg/server/api/connect/v1/schema.graphql index 147f8114c..232a8eaa5 100644 --- a/pkg/server/api/connect/v1/schema.graphql +++ b/pkg/server/api/connect/v1/schema.graphql @@ -181,6 +181,8 @@ type Identity implements Node { type Profile implements Node { id: ID! fullName: String! + source: String! + state: ProfileState! additionalEmailAddresses: [EmailAddr!]! kind: ProfileKind! position: String @@ -198,6 +200,32 @@ type Profile implements Node { @session(required: PRESENT) } +enum ProfileState + @goModel(model: "go.probo.inc/probo/pkg/coredata.ProfileState") { + ACTIVE @goEnum(value: "go.probo.inc/probo/pkg/coredata.ProfileStateActive") + INACTIVE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ProfileStateInactive") +} + +enum ProfileSource + @goModel(model: "go.probo.inc/probo/pkg/coredata.ProfileSource") { + MANUAL + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ProfileSourceManual") + SAML @goEnum(value: "go.probo.inc/probo/pkg/coredata.ProfileSourceSAML") + SCIM @goEnum(value: "go.probo.inc/probo/pkg/coredata.ProfileSourceSCIM") +} + +enum ProfileKind @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipProfileKind") { + EMPLOYEE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipProfileKindEmployee") + CONTRACTOR + @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipProfileKindContractor") + SERVICE_ACCOUNT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.MembershipProfileKindServiceAccount" + ) +} + type Organization implements Node { id: ID! name: String! @@ -243,17 +271,6 @@ type Organization implements Node { @session(required: PRESENT) } -enum ProfileKind @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipProfileKind") { - EMPLOYEE - @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipProfileKindEmployee") - CONTRACTOR - @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipProfileKindContractor") - SERVICE_ACCOUNT - @goEnum( - value: "go.probo.inc/probo/pkg/coredata.MembershipProfileKindServiceAccount" - ) -} - enum MembershipRole @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipRole") { OWNER @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleOwner") @@ -265,27 +282,10 @@ enum MembershipRole @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleAuditor") } -enum MembershipSource - @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipSource") { - MANUAL - @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipSourceManual") - SAML @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipSourceSAML") - SCIM @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipSourceSCIM") -} - -enum MembershipState - @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipState") { - ACTIVE @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipStateActive") - INACTIVE - @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipStateInactive") -} - type Membership implements Node { id: ID! createdAt: Datetime! role: MembershipRole! - source: MembershipSource! - state: MembershipState! lastSession: Session @goField(forceResolver: true) @@ -440,7 +440,7 @@ type SCIMEvent implements Node { requestBody: String responseBody: String errorMessage: String - profile: Profile @goField(forceResolver: true) + userName: String! ipAddress: String! createdAt: Datetime! diff --git a/pkg/server/api/connect/v1/schema/schema.go b/pkg/server/api/connect/v1/schema/schema.go index 05ebdbe9b..d13b80cef 100644 --- a/pkg/server/api/connect/v1/schema/schema.go +++ b/pkg/server/api/connect/v1/schema/schema.go @@ -192,8 +192,6 @@ type ComplexityRoot struct { LastSession func(childComplexity int) int Permission func(childComplexity int, action string) int Role func(childComplexity int) int - Source func(childComplexity int) int - State func(childComplexity int) int } Mutation struct { @@ -299,6 +297,8 @@ type ComplexityRoot struct { Organization func(childComplexity int) int Permission func(childComplexity int, action string) int Position func(childComplexity int) int + Source func(childComplexity int) int + State func(childComplexity int) int UpdatedAt func(childComplexity int) int } @@ -414,10 +414,10 @@ type ComplexityRoot struct { Method func(childComplexity int) int Path func(childComplexity int) int Permission func(childComplexity int, action string) int - Profile func(childComplexity int) int RequestBody func(childComplexity int) int ResponseBody func(childComplexity int) int StatusCode func(childComplexity int) int + UserName func(childComplexity int) int } SCIMEventConnection struct { @@ -605,8 +605,6 @@ type SCIMConfigurationResolver interface { Permission(ctx context.Context, obj *types.SCIMConfiguration, action string) (bool, error) } type SCIMEventResolver interface { - Profile(ctx context.Context, obj *types.SCIMEvent) (*types.Profile, error) - Permission(ctx context.Context, obj *types.SCIMEvent, action string) (bool, error) } type SCIMEventConnectionResolver interface { @@ -1042,18 +1040,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Membership.Role(childComplexity), true - case "Membership.source": - if e.complexity.Membership.Source == nil { - break - } - - return e.complexity.Membership.Source(childComplexity), true - case "Membership.state": - if e.complexity.Membership.State == nil { - break - } - - return e.complexity.Membership.State(childComplexity), true case "Mutation.acceptInvitation": if e.complexity.Mutation.AcceptInvitation == nil { @@ -1706,6 +1692,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Profile.Position(childComplexity), true + case "Profile.source": + if e.complexity.Profile.Source == nil { + break + } + + return e.complexity.Profile.Source(childComplexity), true + case "Profile.state": + if e.complexity.Profile.State == nil { + break + } + + return e.complexity.Profile.State(childComplexity), true case "Profile.updatedAt": if e.complexity.Profile.UpdatedAt == nil { break @@ -2142,12 +2140,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.SCIMEvent.Permission(childComplexity, args["action"].(string)), true - case "SCIMEvent.profile": - if e.complexity.SCIMEvent.Profile == nil { - break - } - - return e.complexity.SCIMEvent.Profile(childComplexity), true case "SCIMEvent.requestBody": if e.complexity.SCIMEvent.RequestBody == nil { break @@ -2166,6 +2158,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.SCIMEvent.StatusCode(childComplexity), true + case "SCIMEvent.userName": + if e.complexity.SCIMEvent.UserName == nil { + break + } + + return e.complexity.SCIMEvent.UserName(childComplexity), true case "SCIMEventConnection.edges": if e.complexity.SCIMEventConnection.Edges == nil { @@ -2683,6 +2681,8 @@ type Identity implements Node { type Profile implements Node { id: ID! fullName: String! + source: String! + state: ProfileState! additionalEmailAddresses: [EmailAddr!]! kind: ProfileKind! position: String @@ -2700,6 +2700,32 @@ type Profile implements Node { @session(required: PRESENT) } +enum ProfileState + @goModel(model: "go.probo.inc/probo/pkg/coredata.ProfileState") { + ACTIVE @goEnum(value: "go.probo.inc/probo/pkg/coredata.ProfileStateActive") + INACTIVE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ProfileStateInactive") +} + +enum ProfileSource + @goModel(model: "go.probo.inc/probo/pkg/coredata.ProfileSource") { + MANUAL + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ProfileSourceManual") + SAML @goEnum(value: "go.probo.inc/probo/pkg/coredata.ProfileSourceSAML") + SCIM @goEnum(value: "go.probo.inc/probo/pkg/coredata.ProfileSourceSCIM") +} + +enum ProfileKind @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipProfileKind") { + EMPLOYEE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipProfileKindEmployee") + CONTRACTOR + @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipProfileKindContractor") + SERVICE_ACCOUNT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.MembershipProfileKindServiceAccount" + ) +} + type Organization implements Node { id: ID! name: String! @@ -2745,17 +2771,6 @@ type Organization implements Node { @session(required: PRESENT) } -enum ProfileKind @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipProfileKind") { - EMPLOYEE - @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipProfileKindEmployee") - CONTRACTOR - @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipProfileKindContractor") - SERVICE_ACCOUNT - @goEnum( - value: "go.probo.inc/probo/pkg/coredata.MembershipProfileKindServiceAccount" - ) -} - enum MembershipRole @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipRole") { OWNER @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleOwner") @@ -2767,27 +2782,10 @@ enum MembershipRole @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleAuditor") } -enum MembershipSource - @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipSource") { - MANUAL - @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipSourceManual") - SAML @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipSourceSAML") - SCIM @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipSourceSCIM") -} - -enum MembershipState - @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipState") { - ACTIVE @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipStateActive") - INACTIVE - @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipStateInactive") -} - type Membership implements Node { id: ID! createdAt: Datetime! role: MembershipRole! - source: MembershipSource! - state: MembershipState! lastSession: Session @goField(forceResolver: true) @@ -2942,7 +2940,7 @@ type SCIMEvent implements Node { requestBody: String responseBody: String errorMessage: String - profile: Profile @goField(forceResolver: true) + userName: String! ipAddress: String! createdAt: Datetime! @@ -4335,10 +4333,6 @@ func (ec *executionContext) fieldContext_AcceptInvitationPayload_membership(_ co return ec.fieldContext_Membership_createdAt(ctx, field) case "role": return ec.fieldContext_Membership_role(ctx, field) - case "source": - return ec.fieldContext_Membership_source(ctx, field) - case "state": - return ec.fieldContext_Membership_state(ctx, field) case "lastSession": return ec.fieldContext_Membership_lastSession(ctx, field) case "permission": @@ -4756,10 +4750,6 @@ func (ec *executionContext) fieldContext_CreateOrganizationPayload_membership(_ return ec.fieldContext_Membership_createdAt(ctx, field) case "role": return ec.fieldContext_Membership_role(ctx, field) - case "source": - return ec.fieldContext_Membership_source(ctx, field) - case "state": - return ec.fieldContext_Membership_state(ctx, field) case "lastSession": return ec.fieldContext_Membership_lastSession(ctx, field) case "permission": @@ -6338,64 +6328,6 @@ func (ec *executionContext) fieldContext_Membership_role(_ context.Context, fiel return fc, nil } -func (ec *executionContext) _Membership_source(ctx context.Context, field graphql.CollectedField, obj *types.Membership) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_Membership_source, - func(ctx context.Context) (any, error) { - return obj.Source, nil - }, - nil, - ec.marshalNMembershipSource2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipSource, - true, - true, - ) -} - -func (ec *executionContext) fieldContext_Membership_source(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "Membership", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type MembershipSource does not have child fields") - }, - } - return fc, nil -} - -func (ec *executionContext) _Membership_state(ctx context.Context, field graphql.CollectedField, obj *types.Membership) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_Membership_state, - func(ctx context.Context) (any, error) { - return obj.State, nil - }, - nil, - ec.marshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState, - true, - true, - ) -} - -func (ec *executionContext) fieldContext_Membership_state(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "Membership", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type MembershipState does not have child fields") - }, - } - return fc, nil -} - func (ec *executionContext) _Membership_lastSession(ctx context.Context, field graphql.CollectedField, obj *types.Membership) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -8921,6 +8853,10 @@ func (ec *executionContext) fieldContext_Organization_viewer(_ context.Context, return ec.fieldContext_Profile_id(ctx, field) case "fullName": return ec.fieldContext_Profile_fullName(ctx, field) + case "source": + return ec.fieldContext_Profile_source(ctx, field) + case "state": + return ec.fieldContext_Profile_state(ctx, field) case "additionalEmailAddresses": return ec.fieldContext_Profile_additionalEmailAddresses(ctx, field) case "kind": @@ -9086,10 +9022,6 @@ func (ec *executionContext) fieldContext_OrganizationSessionCreated_membership(_ return ec.fieldContext_Membership_createdAt(ctx, field) case "role": return ec.fieldContext_Membership_role(ctx, field) - case "source": - return ec.fieldContext_Membership_source(ctx, field) - case "state": - return ec.fieldContext_Membership_state(ctx, field) case "lastSession": return ec.fieldContext_Membership_lastSession(ctx, field) case "permission": @@ -9714,6 +9646,64 @@ func (ec *executionContext) fieldContext_Profile_fullName(_ context.Context, fie return fc, nil } +func (ec *executionContext) _Profile_source(ctx context.Context, field graphql.CollectedField, obj *types.Profile) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Profile_source, + func(ctx context.Context) (any, error) { + return obj.Source, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Profile_source(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Profile", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Profile_state(ctx context.Context, field graphql.CollectedField, obj *types.Profile) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Profile_state, + func(ctx context.Context) (any, error) { + return obj.State, nil + }, + nil, + ec.marshalNProfileState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐProfileState, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Profile_state(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Profile", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ProfileState does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Profile_additionalEmailAddresses(ctx context.Context, field graphql.CollectedField, obj *types.Profile) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -10063,10 +10053,6 @@ func (ec *executionContext) fieldContext_Profile_membership(_ context.Context, f return ec.fieldContext_Membership_createdAt(ctx, field) case "role": return ec.fieldContext_Membership_role(ctx, field) - case "source": - return ec.fieldContext_Membership_source(ctx, field) - case "state": - return ec.fieldContext_Membership_state(ctx, field) case "lastSession": return ec.fieldContext_Membership_lastSession(ctx, field) case "permission": @@ -10297,6 +10283,10 @@ func (ec *executionContext) fieldContext_ProfileEdge_node(_ context.Context, fie return ec.fieldContext_Profile_id(ctx, field) case "fullName": return ec.fieldContext_Profile_fullName(ctx, field) + case "source": + return ec.fieldContext_Profile_source(ctx, field) + case "state": + return ec.fieldContext_Profile_state(ctx, field) case "additionalEmailAddresses": return ec.fieldContext_Profile_additionalEmailAddresses(ctx, field) case "kind": @@ -12488,58 +12478,30 @@ func (ec *executionContext) fieldContext_SCIMEvent_errorMessage(_ context.Contex return fc, nil } -func (ec *executionContext) _SCIMEvent_profile(ctx context.Context, field graphql.CollectedField, obj *types.SCIMEvent) (ret graphql.Marshaler) { +func (ec *executionContext) _SCIMEvent_userName(ctx context.Context, field graphql.CollectedField, obj *types.SCIMEvent) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, ec.OperationContext, field, - ec.fieldContext_SCIMEvent_profile, + ec.fieldContext_SCIMEvent_userName, func(ctx context.Context) (any, error) { - return ec.resolvers.SCIMEvent().Profile(ctx, obj) + return obj.UserName, nil }, nil, - ec.marshalOProfile2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐProfile, + ec.marshalNString2string, + true, true, - false, ) } -func (ec *executionContext) fieldContext_SCIMEvent_profile(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_SCIMEvent_userName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "SCIMEvent", Field: field, - IsMethod: true, - IsResolver: true, + IsMethod: false, + IsResolver: false, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - switch field.Name { - case "id": - return ec.fieldContext_Profile_id(ctx, field) - case "fullName": - return ec.fieldContext_Profile_fullName(ctx, field) - case "additionalEmailAddresses": - return ec.fieldContext_Profile_additionalEmailAddresses(ctx, field) - case "kind": - return ec.fieldContext_Profile_kind(ctx, field) - case "position": - return ec.fieldContext_Profile_position(ctx, field) - case "contractStartDate": - return ec.fieldContext_Profile_contractStartDate(ctx, field) - case "contractEndDate": - return ec.fieldContext_Profile_contractEndDate(ctx, field) - case "createdAt": - return ec.fieldContext_Profile_createdAt(ctx, field) - case "updatedAt": - return ec.fieldContext_Profile_updatedAt(ctx, field) - case "identity": - return ec.fieldContext_Profile_identity(ctx, field) - case "organization": - return ec.fieldContext_Profile_organization(ctx, field) - case "membership": - return ec.fieldContext_Profile_membership(ctx, field) - case "permission": - return ec.fieldContext_Profile_permission(ctx, field) - } - return nil, fmt.Errorf("no field named %q was found under type Profile", field.Name) + return nil, errors.New("field of type String does not have child fields") }, } return fc, nil @@ -12803,8 +12765,8 @@ func (ec *executionContext) fieldContext_SCIMEventEdge_node(_ context.Context, f return ec.fieldContext_SCIMEvent_responseBody(ctx, field) case "errorMessage": return ec.fieldContext_SCIMEvent_errorMessage(ctx, field) - case "profile": - return ec.fieldContext_SCIMEvent_profile(ctx, field) + case "userName": + return ec.fieldContext_SCIMEvent_userName(ctx, field) case "ipAddress": return ec.fieldContext_SCIMEvent_ipAddress(ctx, field) case "createdAt": @@ -13585,10 +13547,6 @@ func (ec *executionContext) fieldContext_UpdateMembershipPayload_membership(_ co return ec.fieldContext_Membership_createdAt(ctx, field) case "role": return ec.fieldContext_Membership_role(ctx, field) - case "source": - return ec.fieldContext_Membership_source(ctx, field) - case "state": - return ec.fieldContext_Membership_state(ctx, field) case "lastSession": return ec.fieldContext_Membership_lastSession(ctx, field) case "permission": @@ -13691,6 +13649,10 @@ func (ec *executionContext) fieldContext_UpdateProfilePayload_profile(_ context. return ec.fieldContext_Profile_id(ctx, field) case "fullName": return ec.fieldContext_Profile_fullName(ctx, field) + case "source": + return ec.fieldContext_Profile_source(ctx, field) + case "state": + return ec.fieldContext_Profile_state(ctx, field) case "additionalEmailAddresses": return ec.fieldContext_Profile_additionalEmailAddresses(ctx, field) case "kind": @@ -18019,16 +17981,6 @@ func (ec *executionContext) _Membership(ctx context.Context, sel ast.SelectionSe if out.Values[i] == graphql.Null { atomic.AddUint32(&out.Invalids, 1) } - case "source": - out.Values[i] = ec._Membership_source(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) - } - case "state": - out.Values[i] = ec._Membership_state(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) - } case "lastSession": field := field @@ -19023,6 +18975,16 @@ func (ec *executionContext) _Profile(ctx context.Context, sel ast.SelectionSet, if out.Values[i] == graphql.Null { atomic.AddUint32(&out.Invalids, 1) } + case "source": + out.Values[i] = ec._Profile_source(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "state": + out.Values[i] = ec._Profile_state(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } case "additionalEmailAddresses": out.Values[i] = ec._Profile_additionalEmailAddresses(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -20471,39 +20433,11 @@ func (ec *executionContext) _SCIMEvent(ctx context.Context, sel ast.SelectionSet out.Values[i] = ec._SCIMEvent_responseBody(ctx, field, obj) case "errorMessage": out.Values[i] = ec._SCIMEvent_errorMessage(ctx, field, obj) - case "profile": - 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._SCIMEvent_profile(ctx, field, obj) - return res + case "userName": + out.Values[i] = ec._SCIMEvent_userName(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) } - - 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 "ipAddress": out.Values[i] = ec._SCIMEvent_ipAddress(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -22073,64 +22007,6 @@ var ( } ) -func (ec *executionContext) unmarshalNMembershipSource2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipSource(ctx context.Context, v any) (coredata.MembershipSource, error) { - tmp, err := graphql.UnmarshalString(v) - res := unmarshalNMembershipSource2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipSource[tmp] - return res, graphql.ErrorOnPath(ctx, err) -} - -func (ec *executionContext) marshalNMembershipSource2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipSource(ctx context.Context, sel ast.SelectionSet, v coredata.MembershipSource) graphql.Marshaler { - _ = sel - res := graphql.MarshalString(marshalNMembershipSource2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipSource[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 ( - unmarshalNMembershipSource2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipSource = map[string]coredata.MembershipSource{ - "MANUAL": coredata.MembershipSourceManual, - "SAML": coredata.MembershipSourceSAML, - "SCIM": coredata.MembershipSourceSCIM, - } - marshalNMembershipSource2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipSource = map[coredata.MembershipSource]string{ - coredata.MembershipSourceManual: "MANUAL", - coredata.MembershipSourceSAML: "SAML", - coredata.MembershipSourceSCIM: "SCIM", - } -) - -func (ec *executionContext) unmarshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState(ctx context.Context, v any) (coredata.MembershipState, error) { - tmp, err := graphql.UnmarshalString(v) - res := unmarshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState[tmp] - return res, graphql.ErrorOnPath(ctx, err) -} - -func (ec *executionContext) marshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState(ctx context.Context, sel ast.SelectionSet, v coredata.MembershipState) graphql.Marshaler { - _ = sel - res := graphql.MarshalString(marshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState[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 ( - unmarshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState = map[string]coredata.MembershipState{ - "ACTIVE": coredata.MembershipStateActive, - "INACTIVE": coredata.MembershipStateInactive, - } - marshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState = map[coredata.MembershipState]string{ - coredata.MembershipStateActive: "ACTIVE", - coredata.MembershipStateInactive: "INACTIVE", - } -) - func (ec *executionContext) unmarshalNOrderDirection2goᚗproboᚗincᚋproboᚋpkgᚋpageᚐOrderDirection(ctx context.Context, v any) (page.OrderDirection, error) { tmp, err := graphql.UnmarshalString(v) res := unmarshalNOrderDirection2goᚗproboᚗincᚋproboᚋpkgᚋpageᚐOrderDirection[tmp] @@ -22363,6 +22239,34 @@ var ( } ) +func (ec *executionContext) unmarshalNProfileState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐProfileState(ctx context.Context, v any) (coredata.ProfileState, error) { + tmp, err := graphql.UnmarshalString(v) + res := unmarshalNProfileState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐProfileState[tmp] + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNProfileState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐProfileState(ctx context.Context, sel ast.SelectionSet, v coredata.ProfileState) graphql.Marshaler { + _ = sel + res := graphql.MarshalString(marshalNProfileState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐProfileState[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 ( + unmarshalNProfileState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐProfileState = map[string]coredata.ProfileState{ + "ACTIVE": coredata.ProfileStateActive, + "INACTIVE": coredata.ProfileStateInactive, + } + marshalNProfileState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐProfileState = map[coredata.ProfileState]string{ + coredata.ProfileStateActive: "ACTIVE", + coredata.ProfileStateInactive: "INACTIVE", + } +) + func (ec *executionContext) unmarshalNReauthenticationReason2goᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐReauthenticationReason(ctx context.Context, v any) (types.ReauthenticationReason, error) { var res types.ReauthenticationReason err := res.UnmarshalGQL(v) diff --git a/pkg/server/api/connect/v1/scim_handler.go b/pkg/server/api/connect/v1/scim_handler.go index 67891340f..44428bf71 100644 --- a/pkg/server/api/connect/v1/scim_handler.go +++ b/pkg/server/api/connect/v1/scim_handler.go @@ -46,13 +46,13 @@ type ( } scimRequestContext struct { - ctx context.Context - config *coredata.SCIMConfiguration - ipAddress net.IP - method string - path string - membershipID *gid.GID - handler *scimResourceHandler + ctx context.Context + config *coredata.SCIMConfiguration + ipAddress net.IP + method string + path string + userName string + handler *scimResourceHandler } ) @@ -145,23 +145,23 @@ func (rc *scimRequestContext) logAndWrapError(err error, logMsg string) error { if errors.As(err, &scimErr) { errMsg := scimErr.Detail - // Don't reference membershipID for 404 errors - the resource doesn't exist - membershipID := rc.membershipID + // Don't reference profileID for 404 errors - the resource doesn't exist + userName := rc.userName if scimErr.Status == http.StatusNotFound { - membershipID = nil + userName = "" } - rc.handler.handler.iam.SCIMService.LogEvent(rc.ctx, rc.config, rc.method, rc.path, membershipID, rc.ipAddress, scimErr.Status, &errMsg) + rc.handler.handler.iam.SCIMService.LogEvent(rc.ctx, rc.config, rc.method, rc.path, userName, rc.ipAddress, scimErr.Status, &errMsg) return err } rc.handler.handler.logger.ErrorCtx(rc.ctx, logMsg, log.Error(err)) errMsg := "internal server error" - rc.handler.handler.iam.SCIMService.LogEvent(rc.ctx, rc.config, rc.method, rc.path, rc.membershipID, rc.ipAddress, 500, &errMsg) + rc.handler.handler.iam.SCIMService.LogEvent(rc.ctx, rc.config, rc.method, rc.path, rc.userName, rc.ipAddress, 500, &errMsg) return scimerrors.ScimErrorInternal } func (rc *scimRequestContext) logSuccess(statusCode int) { - rc.handler.handler.iam.SCIMService.LogEvent(rc.ctx, rc.config, rc.method, rc.path, rc.membershipID, rc.ipAddress, statusCode, nil) + rc.handler.handler.iam.SCIMService.LogEvent(rc.ctx, rc.config, rc.method, rc.path, rc.userName, rc.ipAddress, statusCode, nil) } func (h *scimResourceHandler) Create(r *http.Request, attributes scim.ResourceAttributes) (scim.Resource, error) { @@ -179,9 +179,10 @@ func (h *scimResourceHandler) Create(r *http.Request, attributes scim.ResourceAt return scim.Resource{}, rc.logAndWrapError(err, "cannot create user") } - membershipID, _ := gid.ParseGID(resource.ID) - rc.membershipID = &membershipID + rc.userName = resource.Attributes["userName"].(string) + rc.logSuccess(201) + return resource, nil } @@ -195,18 +196,20 @@ func (h *scimResourceHandler) Get(r *http.Request, id string) (scim.Resource, er handler: h, } - membershipID, err := gid.ParseGID(id) + profileID, err := gid.ParseGID(id) if err != nil { - return scim.Resource{}, rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid membership ID") + return scim.Resource{}, rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid profile ID") } - rc.membershipID = &membershipID - resource, err := h.handler.iam.SCIMService.GetUser(rc.ctx, rc.config, membershipID) + resource, err := h.handler.iam.SCIMService.GetUser(rc.ctx, rc.config, profileID) if err != nil { return scim.Resource{}, rc.logAndWrapError(err, "cannot get user") } + rc.userName = resource.Attributes["userName"].(string) + rc.logSuccess(200) + return resource, nil } @@ -256,18 +259,20 @@ func (h *scimResourceHandler) Replace(r *http.Request, id string, attributes sci handler: h, } - membershipID, err := gid.ParseGID(id) + profileID, err := gid.ParseGID(id) if err != nil { - return scim.Resource{}, rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid membership ID") + return scim.Resource{}, rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid profile ID") } - rc.membershipID = &membershipID - resource, err := h.handler.iam.SCIMService.ReplaceUser(rc.ctx, rc.config, membershipID, attributes) + resource, err := h.handler.iam.SCIMService.ReplaceUser(rc.ctx, rc.config, profileID, attributes) if err != nil { return scim.Resource{}, rc.logAndWrapError(err, "cannot update user") } + rc.userName = resource.Attributes["userName"].(string) + rc.logSuccess(200) + return resource, nil } @@ -281,18 +286,20 @@ func (h *scimResourceHandler) Patch(r *http.Request, id string, operations []sci handler: h, } - membershipID, err := gid.ParseGID(id) + profileID, err := gid.ParseGID(id) if err != nil { - return scim.Resource{}, rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid membership ID") + return scim.Resource{}, rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid profile ID") } - rc.membershipID = &membershipID - resource, err := h.handler.iam.SCIMService.PatchUser(rc.ctx, rc.config, membershipID, operations) + resource, err := h.handler.iam.SCIMService.PatchUser(rc.ctx, rc.config, profileID, operations) if err != nil { return scim.Resource{}, rc.logAndWrapError(err, "cannot patch user") } + rc.userName = resource.Attributes["userName"].(string) + rc.logSuccess(200) + return resource, nil } @@ -306,19 +313,20 @@ func (h *scimResourceHandler) Delete(r *http.Request, id string) error { handler: h, } - membershipID, err := gid.ParseGID(id) + profileID, err := gid.ParseGID(id) if err != nil { - return rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid membership ID") + return rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid profile ID") } - rc.membershipID = &membershipID - err = h.handler.iam.SCIMService.DeleteUser(rc.ctx, rc.config, membershipID) + err = h.handler.iam.SCIMService.DeleteUser(rc.ctx, rc.config, profileID) if err != nil { return rc.logAndWrapError(err, "cannot delete user") } - rc.membershipID = nil + rc.userName = "" + rc.logSuccess(204) + return nil } diff --git a/pkg/server/api/connect/v1/types/membership.go b/pkg/server/api/connect/v1/types/membership.go index 1444d4dc1..67a23fc99 100644 --- a/pkg/server/api/connect/v1/types/membership.go +++ b/pkg/server/api/connect/v1/types/membership.go @@ -23,7 +23,5 @@ func NewMembership(membership *coredata.Membership) *Membership { ID: membership.ID, CreatedAt: membership.CreatedAt, Role: membership.Role, - Source: membership.Source, - State: membership.State, } } diff --git a/pkg/server/api/connect/v1/types/profile.go b/pkg/server/api/connect/v1/types/profile.go index 1762c01c2..c3d629dbd 100644 --- a/pkg/server/api/connect/v1/types/profile.go +++ b/pkg/server/api/connect/v1/types/profile.go @@ -66,6 +66,8 @@ func NewProfile(profile *coredata.MembershipProfile) *Profile { return &Profile{ ID: profile.ID, FullName: profile.FullName, + Source: profile.Source.String(), + State: profile.State, AdditionalEmailAddresses: profile.AdditionalEmailAddresses, Kind: profile.Kind, Position: profile.Position, diff --git a/pkg/server/api/connect/v1/types/scim_event.go b/pkg/server/api/connect/v1/types/scim_event.go index 056cbda28..ec385503f 100644 --- a/pkg/server/api/connect/v1/types/scim_event.go +++ b/pkg/server/api/connect/v1/types/scim_event.go @@ -64,6 +64,7 @@ func NewSCIMEvent(scimEvent *coredata.SCIMEvent) *SCIMEvent { ID: scimEvent.ID, Method: scimEvent.Method, Path: scimEvent.Path, + UserName: scimEvent.UserName, StatusCode: scimEvent.StatusCode, RequestBody: scimEvent.RequestBody, ResponseBody: scimEvent.ResponseBody, @@ -72,12 +73,5 @@ func NewSCIMEvent(scimEvent *coredata.SCIMEvent) *SCIMEvent { CreatedAt: scimEvent.CreatedAt, } - // FIXME: profile - // if scimEvent.MembershipID != nil { - // event.Membership = &Membership{ - // ID: *scimEvent.MembershipID, - // } - // } - return event } diff --git a/pkg/server/api/connect/v1/types/types.go b/pkg/server/api/connect/v1/types/types.go index 447cb7f5f..446bd9007 100644 --- a/pkg/server/api/connect/v1/types/types.go +++ b/pkg/server/api/connect/v1/types/types.go @@ -220,13 +220,11 @@ type InviteMemberPayload struct { } type Membership struct { - ID gid.GID `json:"id"` - CreatedAt time.Time `json:"createdAt"` - Role coredata.MembershipRole `json:"role"` - Source coredata.MembershipSource `json:"source"` - State coredata.MembershipState `json:"state"` - LastSession *Session `json:"lastSession,omitempty"` - Permission bool `json:"permission"` + ID gid.GID `json:"id"` + CreatedAt time.Time `json:"createdAt"` + Role coredata.MembershipRole `json:"role"` + LastSession *Session `json:"lastSession,omitempty"` + Permission bool `json:"permission"` } func (Membership) IsNode() {} @@ -298,6 +296,8 @@ type PersonalAPIKeyEdge struct { type Profile struct { ID gid.GID `json:"id"` FullName string `json:"fullName"` + Source string `json:"source"` + State coredata.ProfileState `json:"state"` AdditionalEmailAddresses []mail.Addr `json:"additionalEmailAddresses"` Kind coredata.MembershipProfileKind `json:"kind"` Position *string `json:"position,omitempty"` @@ -452,7 +452,7 @@ type SCIMEvent struct { RequestBody *string `json:"requestBody,omitempty"` ResponseBody *string `json:"responseBody,omitempty"` ErrorMessage *string `json:"errorMessage,omitempty"` - Profile *Profile `json:"profile,omitempty"` + UserName string `json:"userName"` IPAddress string `json:"ipAddress"` CreatedAt time.Time `json:"createdAt"` 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 e6373c32b..1f9997b3e 100644 --- a/pkg/server/api/connect/v1/v1_resolver.go +++ b/pkg/server/api/connect/v1/v1_resolver.go @@ -360,9 +360,9 @@ func (r *mutationResolver) SignIn(ctx context.Context, input types.SignInInput) if err != nil { // Here session middleware already took care of expired/nil root session so we only handle membership related errors var errMembershipNotFound *iam.ErrMembershipNotFound - var errMembershipInactive *iam.ErrMembershipInactive + var errUserInactive *iam.ErrUserInactive - if errors.As(err, &errMembershipNotFound) || errors.As(err, &errMembershipInactive) { + if errors.As(err, &errMembershipNotFound) || errors.As(err, &errUserInactive) { return nil, gqlutils.Forbiddenf(ctx, "forbidden") } @@ -890,13 +890,13 @@ func (r *mutationResolver) InviteMember(ctx context.Context, input types.InviteM ) if err != nil { var errOrganizationNotFound *iam.ErrOrganizationNotFound - var errMembershipAlreadyExists *iam.ErrMembershipAlreadyExists + var errUserAlreadyExists *iam.ErrUserAlreadyExists if errors.As(err, &errOrganizationNotFound) { return nil, gqlutils.NotFound(ctx, err) } - if errors.As(err, &errMembershipAlreadyExists) { + if errors.As(err, &errUserAlreadyExists) { return nil, gqlutils.Conflict(ctx, err) } @@ -994,7 +994,7 @@ func (r *mutationResolver) RemoveMember(ctx context.Context, input types.RemoveM err := r.iam.OrganizationService.RemoveMember(ctx, input.OrganizationID, input.MembershipID) if err != nil { - var errManagedBySCIM *iam.ErrMembershipManagedBySCIM + var errManagedBySCIM *iam.ErrUserManagedBySCIM var errLastActiveOwner *iam.ErrLastActiveOwner if errors.As(err, &errManagedBySCIM) { @@ -1889,11 +1889,6 @@ func (r *sCIMConfigurationResolver) Permission(ctx context.Context, obj *types.S return r.Resolver.Permission(ctx, obj, action) } -// Profile is the resolver for the profile field. -func (r *sCIMEventResolver) Profile(ctx context.Context, obj *types.SCIMEvent) (*types.Profile, error) { - panic(fmt.Errorf("not implemented: Profile - profile")) -} - // Permission is the resolver for the permission field. func (r *sCIMEventResolver) Permission(ctx context.Context, obj *types.SCIMEvent, action string) (bool, error) { return r.Resolver.Permission(ctx, obj, action)