From baeb758a4f92c1729ae289bf0208951390f66f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Fri, 13 Feb 2026 11:54:14 +0400 Subject: [PATCH] Implement resolvers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- .../iam/MembershipCardFragment.graphql.ts | 58 +---- ...ershipCard_organizationFragment.graphql.ts | 58 +++++ ...ershipsDropdownMenuItemFragment.graphql.ts | 39 +--- ...wnMenuItem_organizationFragment.graphql.ts | 58 +++++ .../iam/MembershipsPageQuery.graphql.ts | 38 ++-- .../iam/PeopleListFragment.graphql.ts | 18 +- .../iam/PeopleListItemFragment.graphql.ts | 58 +++-- .../iam/PeoplePageQuery.graphql.ts | 4 +- .../iam/PersonFormFragment.graphql.ts | 6 +- .../iam/SCIMEventListItemFragment.graphql.ts | 46 +--- ...iewerMembershipDropdownFragment.graphql.ts | 43 ++-- pkg/coredata/membership_profile.go | 36 +++ pkg/iam/account_service.go | 53 +++++ pkg/iam/iam_policies.go | 5 +- pkg/iam/organization_service.go | 35 +++ pkg/server/api/connect/v1/v1_resolver.go | 211 +++++++++++++----- 16 files changed, 507 insertions(+), 259 deletions(-) create mode 100644 apps/console/src/__generated__/iam/MembershipCard_organizationFragment.graphql.ts create mode 100644 apps/console/src/__generated__/iam/MembershipsDropdownMenuItem_organizationFragment.graphql.ts diff --git a/apps/console/src/__generated__/iam/MembershipCardFragment.graphql.ts b/apps/console/src/__generated__/iam/MembershipCardFragment.graphql.ts index a77adbf87..a0c171e97 100644 --- a/apps/console/src/__generated__/iam/MembershipCardFragment.graphql.ts +++ b/apps/console/src/__generated__/iam/MembershipCardFragment.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -15,11 +15,6 @@ export type MembershipCardFragment$data = { readonly expiresAt: string; readonly id: string; } | null | undefined; - readonly organization: { - readonly id: string; - readonly logoUrl: string | null | undefined; - readonly name: string; - }; readonly " $fragmentType": "MembershipCardFragment"; }; export type MembershipCardFragment$key = { @@ -27,15 +22,7 @@ export type MembershipCardFragment$key = { readonly " $fragmentSpreads": FragmentRefs<"MembershipCardFragment">; }; -const node: ReaderFragment = (function(){ -var v0 = { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "id", - "storageKey": null -}; -return { +const node: ReaderFragment = { "argumentDefinitions": [], "kind": "Fragment", "metadata": null, @@ -49,7 +36,13 @@ return { "name": "lastSession", "plural": false, "selections": [ - (v0/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, { "alias": null, "args": null, @@ -59,43 +52,12 @@ return { } ], "storageKey": null - }, - { - "kind": "RequiredField", - "field": { - "alias": null, - "args": null, - "concreteType": "Organization", - "kind": "LinkedField", - "name": "organization", - "plural": false, - "selections": [ - (v0/*: any*/), - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "name", - "storageKey": null - }, - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "logoUrl", - "storageKey": null - } - ], - "storageKey": null - }, - "action": "THROW" } ], "type": "Membership", "abstractKey": null }; -})(); -(node as any).hash = "5a2532aed755a94df49c2f9ce0929a1f"; +(node as any).hash = "3da5f340850d352daaa553ca7b78aa37"; export default node; diff --git a/apps/console/src/__generated__/iam/MembershipCard_organizationFragment.graphql.ts b/apps/console/src/__generated__/iam/MembershipCard_organizationFragment.graphql.ts new file mode 100644 index 000000000..f6218532a --- /dev/null +++ b/apps/console/src/__generated__/iam/MembershipCard_organizationFragment.graphql.ts @@ -0,0 +1,58 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ReaderFragment } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type MembershipCard_organizationFragment$data = { + readonly id: string; + readonly logoUrl: string | null | undefined; + readonly name: string; + readonly " $fragmentType": "MembershipCard_organizationFragment"; +}; +export type MembershipCard_organizationFragment$key = { + readonly " $data"?: MembershipCard_organizationFragment$data; + readonly " $fragmentSpreads": FragmentRefs<"MembershipCard_organizationFragment">; +}; + +const node: ReaderFragment = { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "MembershipCard_organizationFragment", + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "logoUrl", + "storageKey": null + } + ], + "type": "Organization", + "abstractKey": null +}; + +(node as any).hash = "a02d28730634e510e7e0803d5d1a366f"; + +export default node; diff --git a/apps/console/src/__generated__/iam/MembershipsDropdownMenuItemFragment.graphql.ts b/apps/console/src/__generated__/iam/MembershipsDropdownMenuItemFragment.graphql.ts index 1e49f94f4..c6d8c9d6a 100644 --- a/apps/console/src/__generated__/iam/MembershipsDropdownMenuItemFragment.graphql.ts +++ b/apps/console/src/__generated__/iam/MembershipsDropdownMenuItemFragment.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -16,11 +16,6 @@ export type MembershipsDropdownMenuItemFragment$data = { readonly expiresAt: string; readonly id: string; } | null | undefined; - readonly organization: { - readonly id: string; - readonly logoUrl: string | null | undefined; - readonly name: string; - }; readonly " $fragmentType": "MembershipsDropdownMenuItemFragment"; }; export type MembershipsDropdownMenuItemFragment$key = { @@ -61,36 +56,6 @@ return { } ], "storageKey": null - }, - { - "kind": "RequiredField", - "field": { - "alias": null, - "args": null, - "concreteType": "Organization", - "kind": "LinkedField", - "name": "organization", - "plural": false, - "selections": [ - (v0/*: any*/), - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "logoUrl", - "storageKey": null - }, - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "name", - "storageKey": null - } - ], - "storageKey": null - }, - "action": "THROW" } ], "type": "Membership", @@ -98,6 +63,6 @@ return { }; })(); -(node as any).hash = "915a6fb0071b4890010401a56f3affa6"; +(node as any).hash = "4f40e957597476f8e6179f6df68d386e"; export default node; diff --git a/apps/console/src/__generated__/iam/MembershipsDropdownMenuItem_organizationFragment.graphql.ts b/apps/console/src/__generated__/iam/MembershipsDropdownMenuItem_organizationFragment.graphql.ts new file mode 100644 index 000000000..2f6f01a22 --- /dev/null +++ b/apps/console/src/__generated__/iam/MembershipsDropdownMenuItem_organizationFragment.graphql.ts @@ -0,0 +1,58 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ReaderFragment } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type MembershipsDropdownMenuItem_organizationFragment$data = { + readonly id: string; + readonly logoUrl: string | null | undefined; + readonly name: string; + readonly " $fragmentType": "MembershipsDropdownMenuItem_organizationFragment"; +}; +export type MembershipsDropdownMenuItem_organizationFragment$key = { + readonly " $data"?: MembershipsDropdownMenuItem_organizationFragment$data; + readonly " $fragmentSpreads": FragmentRefs<"MembershipsDropdownMenuItem_organizationFragment">; +}; + +const node: ReaderFragment = { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "MembershipsDropdownMenuItem_organizationFragment", + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "logoUrl", + "storageKey": null + } + ], + "type": "Organization", + "abstractKey": null +}; + +(node as any).hash = "799a0d538f5d667112f4c9ba8c0d3623"; + +export default node; diff --git a/apps/console/src/__generated__/iam/MembershipsPageQuery.graphql.ts b/apps/console/src/__generated__/iam/MembershipsPageQuery.graphql.ts index 2135da3a2..1817c4797 100644 --- a/apps/console/src/__generated__/iam/MembershipsPageQuery.graphql.ts +++ b/apps/console/src/__generated__/iam/MembershipsPageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -29,7 +29,7 @@ export type MembershipsPageQuery$data = { readonly id: string; readonly membership: { readonly " $fragmentSpreads": FragmentRefs<"MembershipCardFragment">; - } | null | undefined; + }; readonly organization: { readonly name: string; readonly " $fragmentSpreads": FragmentRefs<"MembershipCard_organizationFragment">; @@ -191,20 +191,24 @@ return { "selections": [ (v1/*: any*/), { - "alias": null, - "args": null, - "concreteType": "Membership", - "kind": "LinkedField", - "name": "membership", - "plural": false, - "selections": [ - { - "args": null, - "kind": "FragmentSpread", - "name": "MembershipCardFragment" - } - ], - "storageKey": null + "kind": "RequiredField", + "field": { + "alias": null, + "args": null, + "concreteType": "Membership", + "kind": "LinkedField", + "name": "membership", + "plural": false, + "selections": [ + { + "args": null, + "kind": "FragmentSpread", + "name": "MembershipCardFragment" + } + ], + "storageKey": null + }, + "action": "THROW" }, { "kind": "RequiredField", @@ -530,6 +534,6 @@ return { }; })(); -(node as any).hash = "05ac7c8fa236be3a01eeb28fc14ceb9d"; +(node as any).hash = "84f5ab240bec94caf75e9ae163117e3d"; export default node; diff --git a/apps/console/src/__generated__/iam/PeopleListFragment.graphql.ts b/apps/console/src/__generated__/iam/PeopleListFragment.graphql.ts index 5926df39a..88ec7babf 100644 --- a/apps/console/src/__generated__/iam/PeopleListFragment.graphql.ts +++ b/apps/console/src/__generated__/iam/PeopleListFragment.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<2d667e47f47ffe754960a9336fc4b8dc>> * @lightSyntaxTransform * @nogrep */ @@ -12,7 +12,7 @@ import { ReaderFragment } from 'relay-runtime'; import { FragmentRefs } from "relay-runtime"; export type PeopleListFragment$data = { readonly id: string; - readonly members: { + readonly profiles: { readonly __id: string; readonly edges: ReadonlyArray<{ readonly node: { @@ -33,7 +33,7 @@ import PeopleListFragment_RefetchQuery_graphql from './PeopleListFragment_Refetc const node: ReaderFragment = (function(){ var v0 = [ - "members" + "profiles" ], v1 = { "alias": null, @@ -110,7 +110,7 @@ return { { "kind": "RequiredField", "field": { - "alias": "members", + "alias": "profiles", "args": [ { "kind": "Variable", @@ -118,9 +118,9 @@ return { "variableName": "order" } ], - "concreteType": "MembershipConnection", + "concreteType": "ProfileConnection", "kind": "LinkedField", - "name": "__PeopleListFragment_members_connection", + "name": "__PeopleListFragment_profiles_connection", "plural": false, "selections": [ { @@ -135,7 +135,7 @@ return { "field": { "alias": null, "args": null, - "concreteType": "MembershipEdge", + "concreteType": "ProfileEdge", "kind": "LinkedField", "name": "edges", "plural": true, @@ -143,7 +143,7 @@ return { { "alias": null, "args": null, - "concreteType": "Membership", + "concreteType": "Profile", "kind": "LinkedField", "name": "node", "plural": false, @@ -239,6 +239,6 @@ return { }; })(); -(node as any).hash = "c62c77bc2ef0e36b7eaf4553cc721e0c"; +(node as any).hash = "ee3aeca404063a0660eeb9ff27592e86"; export default node; diff --git a/apps/console/src/__generated__/iam/PeopleListItemFragment.graphql.ts b/apps/console/src/__generated__/iam/PeopleListItemFragment.graphql.ts index 2327cfef0..1bb81efc7 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<<6739397aaeb3d7890fd697ab4323736a>> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -18,19 +18,20 @@ export type PeopleListItemFragment$data = { readonly canDelete: boolean; readonly canUpdate: boolean; readonly createdAt: string; + readonly fullName: string; readonly id: string; readonly identity: { readonly email: string; }; - readonly profile: { - readonly fullName: string; + readonly kind: ProfileKind; + readonly membership: { + readonly canUpdate: boolean; readonly id: string; - readonly kind: ProfileKind; - readonly position: string | null | undefined; + readonly role: MembershipRole; + readonly source: MembershipSource; + readonly state: MembershipState; }; - readonly role: MembershipRole; - readonly source: MembershipSource; - readonly state: MembershipState; + readonly position: string | null | undefined; readonly " $fragmentType": "PeopleListItemFragment"; }; export type PeopleListItemFragment$key = { @@ -57,21 +58,21 @@ return { "alias": null, "args": null, "kind": "ScalarField", - "name": "role", + "name": "fullName", "storageKey": null }, { "alias": null, "args": null, "kind": "ScalarField", - "name": "source", + "name": "kind", "storageKey": null }, { "alias": null, "args": null, "kind": "ScalarField", - "name": "state", + "name": "position", "storageKey": null }, { @@ -79,9 +80,9 @@ return { "field": { "alias": null, "args": null, - "concreteType": "MembershipProfile", + "concreteType": "Membership", "kind": "LinkedField", - "name": "profile", + "name": "membership", "plural": false, "selections": [ (v0/*: any*/), @@ -89,22 +90,35 @@ return { "alias": null, "args": null, "kind": "ScalarField", - "name": "fullName", + "name": "role", "storageKey": null }, { "alias": null, "args": null, "kind": "ScalarField", - "name": "kind", + "name": "source", "storageKey": null }, { "alias": null, "args": null, "kind": "ScalarField", - "name": "position", + "name": "state", "storageKey": null + }, + { + "alias": "canUpdate", + "args": [ + { + "kind": "Literal", + "name": "action", + "value": "iam:membership:update" + } + ], + "kind": "ScalarField", + "name": "permission", + "storageKey": "permission(action:\"iam:membership:update\")" } ], "storageKey": null @@ -146,12 +160,12 @@ return { { "kind": "Literal", "name": "action", - "value": "iam:membership:update" + "value": "iam:membership-profile:update" } ], "kind": "ScalarField", "name": "permission", - "storageKey": "permission(action:\"iam:membership:update\")" + "storageKey": "permission(action:\"iam:membership-profile:update\")" }, { "alias": "canDelete", @@ -159,19 +173,19 @@ return { { "kind": "Literal", "name": "action", - "value": "iam:membership:delete" + "value": "iam:membership-profile:delete" } ], "kind": "ScalarField", "name": "permission", - "storageKey": "permission(action:\"iam:membership:delete\")" + "storageKey": "permission(action:\"iam:membership-profile:delete\")" } ], - "type": "Membership", + "type": "Profile", "abstractKey": null }; })(); -(node as any).hash = "cf1683d15b13975d8f739c3a7aa38d7e"; +(node as any).hash = "5f45ee7c58d50b0a6569c3bd22ffc83f"; export default node; diff --git a/apps/console/src/__generated__/iam/PeoplePageQuery.graphql.ts b/apps/console/src/__generated__/iam/PeoplePageQuery.graphql.ts index b17b19b7b..998c2ee14 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<<53070e63046901877444d5de41cc8624>> * @lightSyntaxTransform * @nogrep */ @@ -22,7 +22,7 @@ export type PeoplePageQuery$data = { readonly totalCount: number | null | undefined; }; readonly profiles: { - readonly totalCount: number; + readonly totalCount: number | null | undefined; }; readonly " $fragmentSpreads": FragmentRefs<"InvitationListFragment" | "PeopleListFragment">; } | { diff --git a/apps/console/src/__generated__/iam/PersonFormFragment.graphql.ts b/apps/console/src/__generated__/iam/PersonFormFragment.graphql.ts index cac3bf24f..9295ee92f 100644 --- a/apps/console/src/__generated__/iam/PersonFormFragment.graphql.ts +++ b/apps/console/src/__generated__/iam/PersonFormFragment.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<1deba62d68487df7458534fef4bed9ff>> + * @generated SignedSource<<6ee89b2ab0c55ac9498bf9186e78ee90>> * @lightSyntaxTransform * @nogrep */ @@ -96,10 +96,10 @@ const node: ReaderFragment = { "storageKey": "permission(action:\"iam:membership-profile:update\")" } ], - "type": "MembershipProfile", + "type": "Profile", "abstractKey": null }; -(node as any).hash = "ba9398e81e237b983ce37f05436439ee"; +(node as any).hash = "9ecc41c74271a3202bbde08b63c92b73"; export default node; diff --git a/apps/console/src/__generated__/iam/SCIMEventListItemFragment.graphql.ts b/apps/console/src/__generated__/iam/SCIMEventListItemFragment.graphql.ts index d7f4fd800..8d29f6ed4 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<<69f50c950d76ce3e882f94f29333caad>> + * @generated SignedSource<<6bc4cdb83f5825b5d606fe579fa8f7a8>> * @lightSyntaxTransform * @nogrep */ @@ -13,16 +13,12 @@ import { FragmentRefs } from "relay-runtime"; export type SCIMEventListItemFragment$data = { readonly createdAt: string; readonly errorMessage: string | null | undefined; - readonly id: string; readonly ipAddress: string; - readonly membership: { - readonly id: string; - readonly profile: { - readonly fullName: string; - } | null | undefined; - } | null | undefined; readonly method: string; readonly path: string; + readonly profile: { + readonly fullName: string; + } | null | undefined; readonly statusCode: number; readonly " $fragmentType": "SCIMEventListItemFragment"; }; @@ -31,21 +27,12 @@ export type SCIMEventListItemFragment$key = { readonly " $fragmentSpreads": FragmentRefs<"SCIMEventListItemFragment">; }; -const node: ReaderFragment = (function(){ -var v0 = { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "id", - "storageKey": null -}; -return { +const node: ReaderFragment = { "argumentDefinitions": [], "kind": "Fragment", "metadata": null, "name": "SCIMEventListItemFragment", "selections": [ - (v0/*: any*/), { "alias": null, "args": null, @@ -91,28 +78,16 @@ return { { "alias": null, "args": null, - "concreteType": "Membership", + "concreteType": "Profile", "kind": "LinkedField", - "name": "membership", + "name": "profile", "plural": false, "selections": [ - (v0/*: any*/), { "alias": null, "args": null, - "concreteType": "MembershipProfile", - "kind": "LinkedField", - "name": "profile", - "plural": false, - "selections": [ - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "fullName", - "storageKey": null - } - ], + "kind": "ScalarField", + "name": "fullName", "storageKey": null } ], @@ -122,8 +97,7 @@ return { "type": "SCIMEvent", "abstractKey": null }; -})(); -(node as any).hash = "0c9c2bc445531d874b8bceae95f227b8"; +(node as any).hash = "b38af9ac9b660d3aab75e00e2c55dfb6"; export default node; diff --git a/apps/console/src/__generated__/iam/ViewerMembershipDropdownFragment.graphql.ts b/apps/console/src/__generated__/iam/ViewerMembershipDropdownFragment.graphql.ts index fb1c9d06d..deaecea0c 100644 --- a/apps/console/src/__generated__/iam/ViewerMembershipDropdownFragment.graphql.ts +++ b/apps/console/src/__generated__/iam/ViewerMembershipDropdownFragment.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -11,14 +11,12 @@ import { ReaderFragment } from 'relay-runtime'; import { FragmentRefs } from "relay-runtime"; export type ViewerMembershipDropdownFragment$data = { - readonly viewerMembership: { + readonly viewer: { + readonly fullName: string; readonly identity: { readonly canListAPIKeys: boolean; readonly email: string; }; - readonly profile: { - readonly fullName: string; - }; }; readonly " $fragmentType": "ViewerMembershipDropdownFragment"; }; @@ -38,11 +36,18 @@ const node: ReaderFragment = { "field": { "alias": null, "args": null, - "concreteType": "Membership", + "concreteType": "Profile", "kind": "LinkedField", - "name": "viewerMembership", + "name": "viewer", "plural": false, "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "fullName", + "storageKey": null + }, { "kind": "RequiredField", "field": { @@ -77,28 +82,6 @@ const node: ReaderFragment = { "storageKey": null }, "action": "THROW" - }, - { - "kind": "RequiredField", - "field": { - "alias": null, - "args": null, - "concreteType": "MembershipProfile", - "kind": "LinkedField", - "name": "profile", - "plural": false, - "selections": [ - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "fullName", - "storageKey": null - } - ], - "storageKey": null - }, - "action": "THROW" } ], "storageKey": null @@ -110,6 +93,6 @@ const node: ReaderFragment = { "abstractKey": null }; -(node as any).hash = "82c179a1ec2b90ce5f12c0959f97150f"; +(node as any).hash = "fd5df361935e3e2bf4eaac1b2b4c5528"; export default node; diff --git a/pkg/coredata/membership_profile.go b/pkg/coredata/membership_profile.go index e694304d5..f76435191 100644 --- a/pkg/coredata/membership_profile.go +++ b/pkg/coredata/membership_profile.go @@ -708,6 +708,42 @@ INNER JOIN signatories ON p.id = signatories.signed_by_profile_id return nil } +func (p *MembershipProfiles) CountByIdentityID( + ctx context.Context, + conn pg.Conn, + identityID gid.GID, + filter *MembershipProfileFilter, +) (int, error) { + q := ` +SELECT + COUNT(*) +FROM + iam_membership_profiles +WHERE + %s + AND %s + AND identity_id = @identity_id +` + + q = fmt.Sprintf(q, filter.SQLFragment()) + + args := pgx.StrictNamedArgs{"identity_id": identityID} + maps.Copy(args, filter.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return 0, fmt.Errorf("cannot query iam_membership_profiles: %w", err) + } + + var count int + err = rows.Scan(&count) + if err != nil { + return 0, fmt.Errorf("cannot collect count: %w", err) + } + + return count, nil +} + func (p *MembershipProfiles) CountByOrganizationID( ctx context.Context, conn pg.Conn, diff --git a/pkg/iam/account_service.go b/pkg/iam/account_service.go index 3cf8ae422..5a92dc624 100644 --- a/pkg/iam/account_service.go +++ b/pkg/iam/account_service.go @@ -843,3 +843,56 @@ func (s AccountService) CountSAMLConfigurationsForEmail( return count, nil } + +func (s *AccountService) ListProfilesForIdentity( + ctx context.Context, + identityID gid.GID, + cursor *page.Cursor[coredata.MembershipProfileOrderField], + filter *coredata.MembershipProfileFilter, +) (*page.Page[*coredata.MembershipProfile, coredata.MembershipProfileOrderField], error) { + var ( + profiles = coredata.MembershipProfiles{} + ) + + err := s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + if err := profiles.LoadByIdentityID(ctx, conn, coredata.NewNoScope(), identityID, cursor, filter); err != nil { + return fmt.Errorf("cannot load profiles: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, err + } + + return page.NewPage(profiles, cursor), nil +} + +func (s AccountService) CountProfiles( + ctx context.Context, + identityID gid.GID, + filter *coredata.MembershipProfileFilter, +) (int, error) { + var ( + count int + ) + + err := s.pg.WithConn( + ctx, + func(conn pg.Conn) (err error) { + profiles := coredata.MembershipProfiles{} + count, err = profiles.CountByIdentityID(ctx, conn, identityID, filter) + if err != nil { + return fmt.Errorf("cannot count profiles: %w", err) + } + + return nil + }, + ) + + return count, err +} diff --git a/pkg/iam/iam_policies.go b/pkg/iam/iam_policies.go index adb096ef9..2c1c46b2f 100644 --- a/pkg/iam/iam_policies.go +++ b/pkg/iam/iam_policies.go @@ -90,7 +90,10 @@ var IAMSelfManageProfilePolicy = policy.NewPolicy( "Self-Manage Profiles", // Users can view their own profiles - policy.Allow(ActionMembershipProfileGet). + policy.Allow( + ActionMembershipProfileGet, + ActionMembershipProfileList, + ). WithSID("view-own-profiles"). When(policy.Equals("principal.id", "resource.identity_id")), ). diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index a97d4388d..a93add892 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -1002,6 +1002,41 @@ func (s *OrganizationService) GetProfile(ctx context.Context, profileID gid.GID) ctx, func(conn pg.Conn) error { if err := profile.LoadByID(ctx, conn, coredata.NewScopeFromObjectID(profileID), profileID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return NewProfileNotFoundError(profileID) + } + + return fmt.Errorf("cannot load profile: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, err + } + + return profile, nil +} + +func (s *OrganizationService) GetProfileForIdentityAndOrganization(ctx context.Context, identityID gid.GID, organizationID gid.GID) (*coredata.MembershipProfile, error) { + profile := &coredata.MembershipProfile{} + + err := s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + if err := profile.LoadByIdentityIDAndOrganizationID( + ctx, + conn, + coredata.NewScopeFromObjectID(organizationID), + identityID, + organizationID, + ); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return NewProfileNotFoundError(gid.Nil) + } + return fmt.Errorf("cannot load profile: %w", err) } diff --git a/pkg/server/api/connect/v1/v1_resolver.go b/pkg/server/api/connect/v1/v1_resolver.go index 4845aff56..88adf214f 100644 --- a/pkg/server/api/connect/v1/v1_resolver.go +++ b/pkg/server/api/connect/v1/v1_resolver.go @@ -23,7 +23,6 @@ import ( "go.probo.inc/probo/pkg/server/api/authz" "go.probo.inc/probo/pkg/server/api/connect/v1/schema" "go.probo.inc/probo/pkg/server/api/connect/v1/types" - types1 "go.probo.inc/probo/pkg/server/api/console/v1/types" "go.probo.inc/probo/pkg/server/gqlutils" "go.probo.inc/probo/pkg/server/gqlutils/types/cursor" ) @@ -34,8 +33,40 @@ func (r *connectorResolver) Permission(ctx context.Context, obj *types.Connector } // Profiles is the resolver for the profiles field. -func (r *identityResolver) Profiles(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types1.ProfileOrderBy) (*types1.ProfileConnection, error) { - panic(fmt.Errorf("not implemented: Profiles - profiles")) +func (r *identityResolver) Profiles(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ProfileOrderBy) (*types.ProfileConnection, error) { + if err := r.authorize(ctx, obj.ID, iam.ActionMembershipProfileList, authz.WithSkipAssumptionCheck()); err != nil { + return nil, err + } + + if gqlutils.OnlyTotalCountSelected(ctx) { + return &types.ProfileConnection{ + Resolver: r, + ParentID: obj.ID, + }, nil + } + + filter := coredata.NewMembershipProfileFilter(nil) + + pageOrderBy := page.OrderBy[coredata.MembershipProfileOrderField]{ + Field: coredata.MembershipProfileOrderFieldFullName, + Direction: page.OrderDirectionAsc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.MembershipProfileOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := cursor.NewCursor(first, after, last, before, pageOrderBy) + + page, err := r.iam.AccountService.ListProfilesForIdentity(ctx, obj.ID, cursor, filter) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list profiles", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewProfileConnection(page, r, obj.ID), nil } // PendingInvitations is the resolver for the pendingInvitations field. @@ -1211,8 +1242,40 @@ func (r *organizationResolver) HorizontalLogoURL(ctx context.Context, obj *types } // Profiles is the resolver for the profiles field. -func (r *organizationResolver) Profiles(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types1.ProfileOrderBy) (*types1.ProfileConnection, error) { - panic(fmt.Errorf("not implemented: Profiles - profiles")) +func (r *organizationResolver) Profiles(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ProfileOrderBy) (*types.ProfileConnection, error) { + if err := r.authorize(ctx, obj.ID, iam.ActionMembershipProfileList); err != nil { + return nil, err + } + + if gqlutils.OnlyTotalCountSelected(ctx) { + return &types.ProfileConnection{ + Resolver: r, + ParentID: obj.ID, + }, nil + } + + filter := coredata.NewMembershipProfileFilter(nil) + + pageOrderBy := page.OrderBy[coredata.MembershipProfileOrderField]{ + Field: coredata.MembershipProfileOrderFieldFullName, + Direction: page.OrderDirectionAsc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.MembershipProfileOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := cursor.NewCursor(first, after, last, before, pageOrderBy) + + page, err := r.iam.OrganizationService.ListProfiles(ctx, obj.ID, cursor, filter) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list profiles", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewProfileConnection(page, r, obj.ID), nil } // Invitations is the resolver for the invitations field. @@ -1305,21 +1368,26 @@ func (r *organizationResolver) ScimConfiguration(ctx context.Context, obj *types return types.NewSCIMConfiguration(config), nil } -// ViewerMembership is the resolver for the viewerMembership field. -func (r *organizationResolver) ViewerMembership(ctx context.Context, obj *types.Organization) (*types.Membership, error) { - if err := r.authorize(ctx, obj.ID, iam.ActionMembershipGet, authz.WithSkipAssumptionCheck()); err != nil { +// Viewer is the resolver for the viewer field. +func (r *organizationResolver) Viewer(ctx context.Context, obj *types.Organization) (*types.Profile, error) { + if err := r.authorize(ctx, obj.ID, iam.ActionMembershipProfileGet); err != nil { return nil, err } identity := authn.IdentityFromContext(ctx) - membership, err := r.iam.AccountService.GetMembershipForOrganization(ctx, identity.ID, obj.ID) + profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, obj.ID) if err != nil { - r.logger.ErrorCtx(ctx, "cannot get membership for organization", log.Error(err)) + var errNotFound *iam.ErrProfileNotFound + if errors.As(err, &errNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + + r.logger.ErrorCtx(ctx, "cannot get profile", log.Error(err)) return nil, gqlutils.Internal(ctx) } - return types.NewMembership(membership), nil + return types.NewProfile(profile), nil } // Permission is the resolver for the permission field. @@ -1372,37 +1440,95 @@ func (r *personalAPIKeyConnectionResolver) TotalCount(ctx context.Context, obj * // Identity is the resolver for the identity field. func (r *profileResolver) Identity(ctx context.Context, obj *types.Profile) (*types.Identity, error) { - panic(fmt.Errorf("not implemented: Identity - identity")) + if err := r.authorize( + ctx, + obj.ID, + iam.ActionMembershipProfileGet, + authz.WithSkipAssumptionCheck(), + ); err != nil { + return nil, err + } + + identity, err := r.iam.AccountService.GetIdentity(ctx, obj.Identity.ID) + if err != nil { + var errNotFound *iam.ErrIdentityNotFound + if errors.As(err, &errNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + + r.logger.ErrorCtx(ctx, "cannot get identity", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewIdentity(identity), nil } // Organization is the resolver for the organization field. func (r *profileResolver) Organization(ctx context.Context, obj *types.Profile) (*types.Organization, error) { - panic(fmt.Errorf("not implemented: Organization - organization")) + if err := r.authorize(ctx, obj.ID, iam.ActionOrganizationGet, authz.WithSkipAssumptionCheck()); err != nil { + return nil, err + } + + organization, err := r.iam.OrganizationService.GetOrganization(ctx, obj.Organization.ID) + if err != nil { + var errNotFound *iam.ErrOrganizationNotFound + if errors.As(err, &errNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + + r.logger.ErrorCtx(ctx, "cannot get organization", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewOrganization(organization), nil } // Membership is the resolver for the membership field. func (r *profileResolver) Membership(ctx context.Context, obj *types.Profile) (*types.Membership, error) { - panic(fmt.Errorf("not implemented: Membership - membership")) + if err := r.authorize(ctx, obj.ID, iam.ActionMembershipGet, authz.WithSkipAssumptionCheck()); err != nil { + return nil, err + } + + membership, err := r.iam.AccountService.GetMembershipForOrganization(ctx, obj.Identity.ID, obj.Organization.ID) + if err != nil { + var errNotFound *iam.ErrMembershipNotFound + if errors.As(err, &errNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + + r.logger.ErrorCtx(ctx, "cannot get membership", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewMembership(membership), nil } // Permission is the resolver for the permission field. func (r *profileResolver) Permission(ctx context.Context, obj *types.Profile, action string) (bool, error) { - panic(fmt.Errorf("not implemented: Permission - permission")) + return r.Resolver.Permission(ctx, obj, action) } // TotalCount is the resolver for the totalCount field. -func (r *profileConnectionResolver) TotalCount(ctx context.Context, obj *types1.ProfileConnection) (int, error) { - panic(fmt.Errorf("not implemented: TotalCount - totalCount")) -} +func (r *profileConnectionResolver) TotalCount(ctx context.Context, obj *types.ProfileConnection) (*int, error) { + switch obj.Resolver.(type) { + case *identityResolver: + count, err := r.iam.AccountService.CountProfiles(ctx, obj.ParentID, coredata.NewMembershipProfileFilter(nil)) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count sprofiles", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &count, nil + case *organizationResolver: + count, err := r.iam.OrganizationService.CountProfiles(ctx, obj.ParentID, coredata.NewMembershipProfileFilter(nil)) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count profiles", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + return &count, nil + } -// Edges is the resolver for the edges field. -func (r *profileConnectionResolver) Edges(ctx context.Context, obj *types1.ProfileConnection) ([]*types.ProfileEdge, error) { - panic(fmt.Errorf("not implemented: Edges - edges")) -} - -// PageInfo is the resolver for the pageInfo field. -func (r *profileConnectionResolver) PageInfo(ctx context.Context, obj *types1.ProfileConnection) (*types.PageInfo, error) { - panic(fmt.Errorf("not implemented: PageInfo - pageInfo")) + r.logger.ErrorCtx(ctx, "unsupported resolver", log.Any("resolver", obj.Resolver)) + return nil, gqlutils.Internal(ctx) } // Node is the resolver for the node field. @@ -1523,12 +1649,14 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error errOrganizationNotFound *iam.ErrOrganizationNotFound errIdentityNotFound *iam.ErrIdentityNotFound errSessionNotFound *iam.ErrSessionNotFound + errProfileNotFound *iam.ErrProfileNotFound errMembershipNotFound *iam.ErrMembershipNotFound errInvitationNotFound *iam.ErrInvitationNotFound isNotFoundErr = errors.As(err, &errOrganizationNotFound) || errors.As(err, &errIdentityNotFound) || errors.As(err, &errSessionNotFound) || + errors.As(err, &errProfileNotFound) || errors.As(err, &errMembershipNotFound) || errors.As(err, &errInvitationNotFound) ) @@ -1761,34 +1889,9 @@ func (r *sCIMConfigurationResolver) Permission(ctx context.Context, obj *types.S return r.Resolver.Permission(ctx, obj, action) } -// Membership is the resolver for the membership field. -func (r *sCIMEventResolver) Membership(ctx context.Context, obj *types.SCIMEvent) (*types.Membership, error) { - if obj.Membership == nil { - return nil, nil - } - - if err := r.authorize(ctx, obj.Membership.ID, iam.ActionMembershipGet); err != nil { - return nil, err - } - - if gqlutils.OnlyIDSelected(ctx) { - return &types.Membership{ - ID: obj.Membership.ID, - }, nil - } - - membership, err := r.iam.GetMembership(ctx, obj.Membership.ID) - if err != nil { - var errMembershipNotFound *iam.ErrMembershipNotFound - if errors.As(err, &errMembershipNotFound) { - return nil, nil - } - - r.logger.ErrorCtx(ctx, "cannot get membership for scim event", log.Error(err)) - return nil, gqlutils.Internal(ctx) - } - - return types.NewMembership(membership), nil +// 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.