From 4b64e59da4b10caf9ead137fb3378573c40b7ad3 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Thu, 11 Jun 2026 15:58:30 +0200 Subject: [PATCH] Introduce access-review source snapshot and normalize naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouple each campaign from the live access-review sources it was started with by introducing a per-campaign source snapshot table (access_review_campaign_sources). The snapshot captures the source name, category, and connector at start time, so a review remains coherent even after the underlying source is edited or deleted. Fetch tracking becomes an append-only log (access_review_campaign_source_fetch_attempts) that preserves every attempt with its own status and error rather than overwriting a single row. Rename the shared access-review tables and enums to use a consistent access_review_ prefix throughout: access_entries → access_review_entries access_sources → access_review_sources access_source_category → access_review_source_category access_entry_* → access_review_entry_* The same rename propagates to every coredata type, service, GraphQL schema, MCP specification, CLI command, frontend component, and e2e test. The accessreview package gains dedicated actions.go and policies.go files for its own IAM policy set, mirroring the agentrun package pattern. Signed-off-by: Bryan Frimin --- .../iam/organizations/_components/Sidebar.tsx | 2 +- ...sx => CreateCsvAccessReviewSourcePage.tsx} | 28 +- ...CreateCsvAccessReviewSourcePageLoader.tsx} | 10 +- ...ourceRow.tsx => AccessReviewSourceRow.tsx} | 60 +- .../_components/EntryDecisionActions.tsx | 18 +- .../_components/EntryFlagSelect.tsx | 18 +- .../_components/accessReviewHelpers.tsx | 15 + .../campaigns/AccessReviewCampaignsTab.tsx | 4 +- .../campaigns/CampaignDetailPage.tsx | 88 ++- ...og.tsx => AddAccessReviewSourceDialog.tsx} | 34 +- ...Dialog.tsx => AddCampaignSourceDialog.tsx} | 42 +- .../CreateAccessReviewCampaignDialog.tsx | 6 +- ...ions.ts => accessReviewSourceMutations.ts} | 12 +- .../sources/AccessReviewSourcesTab.tsx | 62 +- apps/console/src/routes/accessReviewRoutes.ts | 2 +- contrib/claude/authorization.md | 1 + e2e/console/access_review_test.go | 242 +++---- e2e/console/rbac_test.go | 80 +-- e2e/internal/factory/factory.go | 44 +- pkg/accessreview/actions.go | 45 ++ pkg/accessreview/campaign_service.go | 284 +++++--- pkg/accessreview/campaign_types.go | 22 +- pkg/accessreview/drivers/anthropic.go | 4 +- pkg/accessreview/drivers/asana.go | 4 +- pkg/accessreview/drivers/better_stack.go | 4 +- pkg/accessreview/drivers/bitbucket.go | 4 +- pkg/accessreview/drivers/brex.go | 4 +- pkg/accessreview/drivers/clerk.go | 8 +- pkg/accessreview/drivers/clerk_test.go | 6 +- pkg/accessreview/drivers/clickup.go | 4 +- pkg/accessreview/drivers/cloudflare.go | 4 +- pkg/accessreview/drivers/csv.go | 6 +- pkg/accessreview/drivers/datadog.go | 6 +- pkg/accessreview/drivers/datadog_test.go | 6 +- pkg/accessreview/drivers/docusign.go | 4 +- pkg/accessreview/drivers/driver.go | 4 +- pkg/accessreview/drivers/github.go | 6 +- pkg/accessreview/drivers/gitlab.go | 4 +- pkg/accessreview/drivers/google_workspace.go | 4 +- pkg/accessreview/drivers/grafana.go | 4 +- pkg/accessreview/drivers/heroku.go | 8 +- pkg/accessreview/drivers/hubspot.go | 4 +- pkg/accessreview/drivers/intercom.go | 4 +- pkg/accessreview/drivers/linear.go | 6 +- pkg/accessreview/drivers/metabase.go | 4 +- pkg/accessreview/drivers/microsoft_365.go | 4 +- pkg/accessreview/drivers/monday.go | 4 +- pkg/accessreview/drivers/name_resolver.go | 8 +- pkg/accessreview/drivers/neon.go | 4 +- pkg/accessreview/drivers/netlify.go | 4 +- pkg/accessreview/drivers/notion.go | 6 +- pkg/accessreview/drivers/okta.go | 4 +- pkg/accessreview/drivers/onepassword.go | 4 +- .../drivers/onepassword_users_api.go | 4 +- pkg/accessreview/drivers/openai.go | 4 +- pkg/accessreview/drivers/pagerduty.go | 4 +- pkg/accessreview/drivers/posthog.go | 4 +- pkg/accessreview/drivers/probo_memberships.go | 4 +- pkg/accessreview/drivers/qovery.go | 4 +- pkg/accessreview/drivers/render.go | 4 +- pkg/accessreview/drivers/render_test.go | 4 +- pkg/accessreview/drivers/resend.go | 4 +- pkg/accessreview/drivers/resend_test.go | 2 +- pkg/accessreview/drivers/sendgrid.go | 8 +- pkg/accessreview/drivers/sendgrid_test.go | 6 +- pkg/accessreview/drivers/sentry.go | 10 +- pkg/accessreview/drivers/signoz.go | 4 +- pkg/accessreview/drivers/slack.go | 6 +- pkg/accessreview/drivers/supabase.go | 4 +- pkg/accessreview/drivers/tailscale.go | 4 +- pkg/accessreview/drivers/tally.go | 8 +- pkg/accessreview/drivers/vercel.go | 4 +- pkg/accessreview/drivers/zendesk.go | 4 +- pkg/accessreview/drivers/zendesk_test.go | 4 +- ...cess_entry_service.go => entry_service.go} | 215 +++--- pkg/accessreview/policies.go | 60 ++ pkg/accessreview/review_engine.go | 183 +++--- pkg/accessreview/service.go | 39 +- pkg/accessreview/source_name_worker.go | 16 +- ...ss_source_service.go => source_service.go} | 161 ++--- pkg/accessreview/worker.go | 176 ++--- .../campaign/addsource/addsource.go | 12 +- .../access-review/campaign/create/create.go | 2 +- .../campaign/removesource/removesource.go | 12 +- pkg/cmd/access-review/entry/decide/decide.go | 16 +- .../entry/decideall/decideall.go | 16 +- pkg/cmd/access-review/entry/list/list.go | 78 +-- pkg/cmd/access-review/entry/setflag/flag.go | 16 +- pkg/cmd/access-review/source/create/create.go | 12 +- pkg/cmd/access-review/source/delete/delete.go | 8 +- pkg/cmd/access-review/source/list/list.go | 10 +- pkg/cmd/access-review/source/update/update.go | 14 +- pkg/cmd/access-review/source/view/view.go | 6 +- pkg/coredata/access_entry_account_type.go | 70 -- pkg/coredata/access_entry_decision.go | 79 --- pkg/coredata/access_entry_flag.go | 109 ---- pkg/coredata/access_entry_incremental_tag.go | 73 --- pkg/coredata/access_entry_upsert_test.go | 486 -------------- pkg/coredata/access_review_campaign.go | 28 + .../access_review_campaign_scope_system.go | 215 ------ pkg/coredata/access_review_campaign_source.go | 224 +++++++ .../access_review_campaign_source_fetch.go | 305 --------- ...ss_review_campaign_source_fetch_attempt.go | 378 +++++++++++ .../access_review_campaign_source_test.go | 158 +++++ ...access_entry.go => access_review_entry.go} | 290 +++++---- .../access_review_entry_account_type.go | 70 ++ ... access_review_entry_account_type_test.go} | 18 +- pkg/coredata/access_review_entry_decision.go | 79 +++ ...> access_review_entry_decision_history.go} | 62 +- ...o => access_review_entry_decision_test.go} | 18 +- ...ilter.go => access_review_entry_filter.go} | 16 +- pkg/coredata/access_review_entry_flag.go | 109 ++++ ...st.go => access_review_entry_flag_test.go} | 18 +- .../access_review_entry_incremental_tag.go | 73 +++ ...ccess_review_entry_incremental_tag_test.go | 78 +++ ....go => access_review_entry_order_field.go} | 36 +- .../access_review_entry_upsert_test.go | 427 ++++++++++++ ...cess_source.go => access_review_source.go} | 138 ++-- pkg/coredata/access_review_source_category.go | 76 +++ ... => access_review_source_category_test.go} | 18 +- ...go => access_review_source_order_field.go} | 36 +- ...tistics.go => access_review_statistics.go} | 54 +- pkg/coredata/access_source_category.go | 76 --- pkg/coredata/access_source_category_test.go | 78 --- pkg/coredata/auth_method.go | 54 +- pkg/coredata/connector_settings.go | 4 +- pkg/coredata/entity_type_reg.go | 222 ++++--- pkg/coredata/migrations/20260611T010000Z.sql | 151 +++++ pkg/coredata/migrations/20260612T010000Z.sql | 84 +++ pkg/iam/organization_service.go | 4 +- pkg/probo/actions.go | 26 - pkg/probo/policies.go | 3 - pkg/probod/probod.go | 1 + .../v1/access_review_campaign_resolvers.go | 616 ++++++++++-------- .../v1/access_source_provider_config.go | 2 +- pkg/server/api/console/v1/base_resolvers.go | 21 +- .../v1/graphql/access_review_campaign.graphql | 368 ++++++----- .../console/v1/graphql/organization.graphql | 6 +- .../api/console/v1/organization_resolvers.go | 21 +- .../api/console/v1/types/access_review.go | 140 ++-- ...ccess_review_campaign_scope_source_test.go | 89 --- ...ce.go => access_review_campaign_source.go} | 22 +- .../access_review_campaign_source_test.go | 117 ++++ pkg/server/api/mcp/v1/schema.resolvers.go | 267 ++++---- pkg/server/api/mcp/v1/specification.yaml | 248 +++---- pkg/server/api/mcp/v1/types/access_review.go | 82 +-- 146 files changed, 4795 insertions(+), 4069 deletions(-) rename apps/console/src/pages/organizations/access-reviews/{CreateCsvAccessSourcePage.tsx => CreateCsvAccessReviewSourcePage.tsx} (82%) rename apps/console/src/pages/organizations/access-reviews/{CreateCsvAccessSourcePageLoader.tsx => CreateCsvAccessReviewSourcePageLoader.tsx} (73%) rename apps/console/src/pages/organizations/access-reviews/_components/{AccessSourceRow.tsx => AccessReviewSourceRow.tsx} (81%) rename apps/console/src/pages/organizations/access-reviews/dialogs/{AddAccessSourceDialog.tsx => AddAccessReviewSourceDialog.tsx} (94%) rename apps/console/src/pages/organizations/access-reviews/dialogs/{AddCampaignScopeSourceDialog.tsx => AddCampaignSourceDialog.tsx} (81%) rename apps/console/src/pages/organizations/access-reviews/dialogs/{accessSourceMutations.ts => accessReviewSourceMutations.ts} (75%) create mode 100644 pkg/accessreview/actions.go rename pkg/accessreview/{access_entry_service.go => entry_service.go} (59%) create mode 100644 pkg/accessreview/policies.go rename pkg/accessreview/{access_source_service.go => source_service.go} (68%) delete mode 100644 pkg/coredata/access_entry_account_type.go delete mode 100644 pkg/coredata/access_entry_decision.go delete mode 100644 pkg/coredata/access_entry_flag.go delete mode 100644 pkg/coredata/access_entry_incremental_tag.go delete mode 100644 pkg/coredata/access_entry_upsert_test.go delete mode 100644 pkg/coredata/access_review_campaign_scope_system.go create mode 100644 pkg/coredata/access_review_campaign_source.go delete mode 100644 pkg/coredata/access_review_campaign_source_fetch.go create mode 100644 pkg/coredata/access_review_campaign_source_fetch_attempt.go create mode 100644 pkg/coredata/access_review_campaign_source_test.go rename pkg/coredata/{access_entry.go => access_review_entry.go} (64%) create mode 100644 pkg/coredata/access_review_entry_account_type.go rename pkg/coredata/{access_entry_account_type_test.go => access_review_entry_account_type_test.go} (78%) create mode 100644 pkg/coredata/access_review_entry_decision.go rename pkg/coredata/{access_entry_decision_history.go => access_review_entry_decision_history.go} (64%) rename pkg/coredata/{access_entry_flag_test.go => access_review_entry_decision_test.go} (79%) rename pkg/coredata/{access_entry_filter.go => access_review_entry_filter.go} (87%) create mode 100644 pkg/coredata/access_review_entry_flag.go rename pkg/coredata/{access_entry_decision_test.go => access_review_entry_flag_test.go} (80%) create mode 100644 pkg/coredata/access_review_entry_incremental_tag.go create mode 100644 pkg/coredata/access_review_entry_incremental_tag_test.go rename pkg/coredata/{access_entry_order_field.go => access_review_entry_order_field.go} (52%) create mode 100644 pkg/coredata/access_review_entry_upsert_test.go rename pkg/coredata/{access_source.go => access_review_source.go} (69%) create mode 100644 pkg/coredata/access_review_source_category.go rename pkg/coredata/{access_entry_incremental_tag_test.go => access_review_source_category_test.go} (78%) rename pkg/coredata/{access_source_order_field.go => access_review_source_order_field.go} (52%) rename pkg/coredata/{access_entry_statistics.go => access_review_statistics.go} (80%) delete mode 100644 pkg/coredata/access_source_category.go delete mode 100644 pkg/coredata/access_source_category_test.go create mode 100644 pkg/coredata/migrations/20260611T010000Z.sql create mode 100644 pkg/coredata/migrations/20260612T010000Z.sql delete mode 100644 pkg/server/api/console/v1/types/access_review_campaign_scope_source_test.go rename pkg/server/api/console/v1/types/{access_review_campaign_scope_source.go => access_review_campaign_source.go} (59%) create mode 100644 pkg/server/api/console/v1/types/access_review_campaign_source_test.go diff --git a/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx b/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx index b6d1bb33c..1725fbb00 100644 --- a/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx +++ b/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx @@ -69,7 +69,7 @@ const fragment = graphql` action: "core:statement-of-applicability:list" ) canListAccessReviewCampaigns: permission( - action: "core:access-review-campaign:list" + action: "access-review:campaign:list" ) } `; diff --git a/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePage.tsx b/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessReviewSourcePage.tsx similarity index 82% rename from apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePage.tsx rename to apps/console/src/pages/organizations/access-reviews/CreateCsvAccessReviewSourcePage.tsx index e76d9e9b8..a89836094 100644 --- a/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePage.tsx +++ b/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessReviewSourcePage.tsx @@ -27,20 +27,20 @@ import { Link, useNavigate } from "react-router"; import { ConnectionHandler, graphql } from "relay-runtime"; import { z } from "zod"; -import type { accessSourceMutationsCreateMutation } from "#/__generated__/core/accessSourceMutationsCreateMutation.graphql"; -import type { CreateCsvAccessSourcePageQuery } from "#/__generated__/core/CreateCsvAccessSourcePageQuery.graphql"; +import type { accessReviewSourceMutationsCreateMutation } from "#/__generated__/core/accessReviewSourceMutationsCreateMutation.graphql"; +import type { CreateCsvAccessReviewSourcePageQuery } from "#/__generated__/core/CreateCsvAccessReviewSourcePageQuery.graphql"; import { useFormWithSchema } from "#/hooks/useFormWithSchema"; import { useOrganizationId } from "#/hooks/useOrganizationId"; -import { createAccessSourceMutation } from "./dialogs/accessSourceMutations"; +import { createAccessReviewSourceMutation } from "./dialogs/accessReviewSourceMutations"; -export const createCsvAccessSourcePageQuery = graphql` - query CreateCsvAccessSourcePageQuery($organizationId: ID!) { +export const createCsvAccessReviewSourcePageQuery = graphql` + query CreateCsvAccessReviewSourcePageQuery($organizationId: ID!) { organization: node(id: $organizationId) { __typename ... on Organization { id - canCreateSource: permission(action: "core:access-source:create") + canCreateSource: permission(action: "access-review:source:create") } } } @@ -51,10 +51,10 @@ const csvSchema = z.object({ csvData: z.string().min(1), }); -export default function CreateCsvAccessSourcePage({ +export default function CreateCsvAccessReviewSourcePage({ queryRef, }: { - queryRef: PreloadedQuery; + queryRef: PreloadedQuery; }) { const { __ } = useTranslate(); const { toast } = useToast(); @@ -70,19 +70,19 @@ export default function CreateCsvAccessSourcePage({ usePageTitle(__("Add CSV Access Source")); - const { organization } = usePreloadedQuery(createCsvAccessSourcePageQuery, queryRef); + const { organization } = usePreloadedQuery(createCsvAccessReviewSourcePageQuery, queryRef); if (organization.__typename !== "Organization") { throw new Error("Organization not found"); } const connectionId = ConnectionHandler.getConnectionID( organization.id, - "AccessReviewSourcesTab_accessSources", + "AccessReviewSourcesTab_accessReviewSources", ); - const [createAccessSource, isCreating] - = useMutation( - createAccessSourceMutation, + const [createAccessReviewSource, isCreating] + = useMutation( + createAccessReviewSourceMutation, ); if (!organization.canCreateSource) { @@ -96,7 +96,7 @@ export default function CreateCsvAccessSourcePage({ } const onSubmit = (data: z.infer) => { - createAccessSource({ + createAccessReviewSource({ variables: { input: { organizationId, diff --git a/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePageLoader.tsx b/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessReviewSourcePageLoader.tsx similarity index 73% rename from apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePageLoader.tsx rename to apps/console/src/pages/organizations/access-reviews/CreateCsvAccessReviewSourcePageLoader.tsx index adc7984c6..741465877 100644 --- a/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePageLoader.tsx +++ b/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessReviewSourcePageLoader.tsx @@ -15,16 +15,16 @@ import { Suspense, useEffect } from "react"; import { useQueryLoader } from "react-relay"; -import type { CreateCsvAccessSourcePageQuery } from "#/__generated__/core/CreateCsvAccessSourcePageQuery.graphql"; +import type { CreateCsvAccessReviewSourcePageQuery } from "#/__generated__/core/CreateCsvAccessReviewSourcePageQuery.graphql"; import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; import { useOrganizationId } from "#/hooks/useOrganizationId"; -import CreateCsvAccessSourcePage, { createCsvAccessSourcePageQuery } from "./CreateCsvAccessSourcePage"; +import CreateCsvAccessReviewSourcePage, { createCsvAccessReviewSourcePageQuery } from "./CreateCsvAccessReviewSourcePage"; -export default function CreateCsvAccessSourcePageLoader() { +export default function CreateCsvAccessReviewSourcePageLoader() { const organizationId = useOrganizationId(); const [queryRef, loadQuery] - = useQueryLoader(createCsvAccessSourcePageQuery); + = useQueryLoader(createCsvAccessReviewSourcePageQuery); useEffect(() => { loadQuery({ organizationId }); @@ -36,7 +36,7 @@ export default function CreateCsvAccessSourcePageLoader() { return ( }> - + ); } diff --git a/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx b/apps/console/src/pages/organizations/access-reviews/_components/AccessReviewSourceRow.tsx similarity index 81% rename from apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx rename to apps/console/src/pages/organizations/access-reviews/_components/AccessReviewSourceRow.tsx index cc4a99f97..d6af3c18e 100644 --- a/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx +++ b/apps/console/src/pages/organizations/access-reviews/_components/AccessReviewSourceRow.tsx @@ -32,13 +32,13 @@ import { Suspense, useState } from "react"; import { useFragment, useLazyLoadQuery, useMutation } from "react-relay"; import { graphql } from "relay-runtime"; -import type { AccessSourceRowConfigureMutation } from "#/__generated__/core/AccessSourceRowConfigureMutation.graphql"; -import type { AccessSourceRowDeleteMutation } from "#/__generated__/core/AccessSourceRowDeleteMutation.graphql"; -import type { AccessSourceRowFragment$key } from "#/__generated__/core/AccessSourceRowFragment.graphql"; -import type { AccessSourceRowOrgsQuery } from "#/__generated__/core/AccessSourceRowOrgsQuery.graphql"; +import type { AccessReviewSourceRowConfigureMutation } from "#/__generated__/core/AccessReviewSourceRowConfigureMutation.graphql"; +import type { AccessReviewSourceRowDeleteMutation } from "#/__generated__/core/AccessReviewSourceRowDeleteMutation.graphql"; +import type { AccessReviewSourceRowFragment$key } from "#/__generated__/core/AccessReviewSourceRowFragment.graphql"; +import type { AccessReviewSourceRowOrgsQuery } from "#/__generated__/core/AccessReviewSourceRowOrgsQuery.graphql"; const fragment = graphql` - fragment AccessSourceRowFragment on AccessSource { + fragment AccessReviewSourceRowFragment on AccessReviewSource { id name connectorId @@ -50,27 +50,27 @@ const fragment = graphql` selectedOrganization needsConfiguration createdAt - canDelete: permission(action: "core:access-source:delete") + canDelete: permission(action: "access-review:source:delete") } `; -export const deleteAccessSourceMutation = graphql` - mutation AccessSourceRowDeleteMutation( - $input: DeleteAccessSourceInput! +export const deleteAccessReviewSourceMutation = graphql` + mutation AccessReviewSourceRowDeleteMutation( + $input: DeleteAccessReviewSourceInput! $connections: [ID!]! ) { - deleteAccessSource(input: $input) { - deletedAccessSourceId @deleteEdge(connections: $connections) + deleteAccessReviewSource(input: $input) { + deletedAccessReviewSourceId @deleteEdge(connections: $connections) } } `; const configureMutation = graphql` - mutation AccessSourceRowConfigureMutation( - $input: ConfigureAccessSourceInput! + mutation AccessReviewSourceRowConfigureMutation( + $input: ConfigureAccessReviewSourceInput! ) { - configureAccessSource(input: $input) { - accessSource { + configureAccessReviewSource(input: $input) { + accessReviewSource { id selectedOrganization needsConfiguration @@ -80,9 +80,9 @@ const configureMutation = graphql` `; const orgsQuery = graphql` - query AccessSourceRowOrgsQuery($accessSourceId: ID!) { - node(id: $accessSourceId) @required(action: THROW) { - ... on AccessSource { + query AccessReviewSourceRowOrgsQuery($accessReviewSourceId: ID!) { + node(id: $accessReviewSourceId) @required(action: THROW) { + ... on AccessReviewSource { providerOrganizations { slug displayName @@ -93,7 +93,7 @@ const orgsQuery = graphql` `; type Props = { - fKey: AccessSourceRowFragment$key; + fKey: AccessReviewSourceRowFragment$key; connectionId: string; organizationId: string; }; @@ -121,22 +121,22 @@ function sourceLabel(connectorProvider: string | null | undefined): string { } } -export function AccessSourceRow({ fKey, connectionId, organizationId }: Props) { +export function AccessReviewSourceRow({ fKey, connectionId, organizationId }: Props) { const { __ } = useTranslate(); const confirm = useConfirm(); const { toast } = useToast(); const accessSource = useFragment(fragment, fKey); - const [deleteAccessSource] = useMutation(deleteAccessSourceMutation); - const [configure] = useMutation(configureMutation); + const [deleteAccessReviewSource] = useMutation(deleteAccessReviewSourceMutation); + const [configure] = useMutation(configureMutation); const handleDelete = () => { confirm( () => { - deleteAccessSource({ + deleteAccessReviewSource({ variables: { - input: { accessSourceId: accessSource.id }, + input: { accessReviewSourceId: accessSource.id }, connections: [connectionId], }, onCompleted: (_response, errors) => { @@ -176,7 +176,7 @@ export function AccessSourceRow({ fKey, connectionId, organizationId }: Props) { configure({ variables: { input: { - accessSourceId: accessSource.id, + accessReviewSourceId: accessSource.id, organizationSlug: slug, }, }, @@ -261,7 +261,7 @@ export function AccessSourceRow({ fKey, connectionId, organizationId }: Props) { } > @@ -295,18 +295,18 @@ export function AccessSourceRow({ fKey, connectionId, organizationId }: Props) { } function InlineOrgSelect({ - accessSourceId, + accessReviewSourceId, selectedOrganization, onSelect, }: { - accessSourceId: string; + accessReviewSourceId: string; selectedOrganization: string; onSelect: (slug: string) => void; }) { const { __ } = useTranslate(); - const data = useLazyLoadQuery( + const data = useLazyLoadQuery( orgsQuery, - { accessSourceId }, + { accessReviewSourceId }, { fetchPolicy: "store-or-network" }, ); diff --git a/apps/console/src/pages/organizations/access-reviews/_components/EntryDecisionActions.tsx b/apps/console/src/pages/organizations/access-reviews/_components/EntryDecisionActions.tsx index b5cbdc3f0..5b8e13258 100644 --- a/apps/console/src/pages/organizations/access-reviews/_components/EntryDecisionActions.tsx +++ b/apps/console/src/pages/organizations/access-reviews/_components/EntryDecisionActions.tsx @@ -31,16 +31,16 @@ import { useState } from "react"; import { useMutation } from "react-relay"; import { graphql } from "relay-runtime"; -import type { AccessEntryDecision, EntryDecisionActionsMutation } from "#/__generated__/core/EntryDecisionActionsMutation.graphql"; +import type { AccessReviewEntryDecision, EntryDecisionActionsMutation } from "#/__generated__/core/EntryDecisionActionsMutation.graphql"; import { decisionBadgeVariant, decisionLabel } from "./accessReviewHelpers"; const mutation = graphql` mutation EntryDecisionActionsMutation( - $input: RecordAccessEntryDecisionInput! + $input: RecordAccessReviewEntryDecisionInput! ) { - recordAccessEntryDecision(input: $input) { - accessEntry { + recordAccessReviewEntryDecision(input: $input) { + accessReviewEntry { id decision decisionNote @@ -59,16 +59,16 @@ export function EntryDecisionActions({ entryId, decision }: Props) { const { toast } = useToast(); const ref = useDialogRef(); const [editing, setEditing] = useState(false); - const [pendingDecision, setPendingDecision] = useState(null); + const [pendingDecision, setPendingDecision] = useState(null); const [note, setNote] = useState(""); const [recordDecision, isRecording] = useMutation(mutation); - const submitDecision = (decisionValue: AccessEntryDecision, decisionNote?: string) => { + const submitDecision = (decisionValue: AccessReviewEntryDecision, decisionNote?: string) => { recordDecision({ variables: { input: { - accessEntryId: entryId, + accessReviewEntryId: entryId, decision: decisionValue, decisionNote: decisionNote || null, }, @@ -103,14 +103,14 @@ export function EntryDecisionActions({ entryId, decision }: Props) { }); }; - const openNoteDialog = (decisionValue: AccessEntryDecision) => { + const openNoteDialog = (decisionValue: AccessReviewEntryDecision) => { setPendingDecision(decisionValue); setNote(""); ref.current?.open(); }; const handleDecision = (value: string) => { - const decision = value as AccessEntryDecision; + const decision = value as AccessReviewEntryDecision; if (decision === "APPROVED") { submitDecision(decision); } else { diff --git a/apps/console/src/pages/organizations/access-reviews/_components/EntryFlagSelect.tsx b/apps/console/src/pages/organizations/access-reviews/_components/EntryFlagSelect.tsx index b62c12bbf..0e0ed4e06 100644 --- a/apps/console/src/pages/organizations/access-reviews/_components/EntryFlagSelect.tsx +++ b/apps/console/src/pages/organizations/access-reviews/_components/EntryFlagSelect.tsx @@ -20,14 +20,14 @@ import { useRef, useState } from "react"; import { useMutation } from "react-relay"; import { graphql } from "relay-runtime"; -import type { AccessEntryFlag, EntryFlagSelectMutation } from "#/__generated__/core/EntryFlagSelectMutation.graphql"; +import type { AccessReviewEntryFlag, EntryFlagSelectMutation } from "#/__generated__/core/EntryFlagSelectMutation.graphql"; import { flagBadgeVariant, flagGroups, flagLabel } from "./accessReviewHelpers"; const mutation = graphql` - mutation EntryFlagSelectMutation($input: FlagAccessEntryInput!) { - flagAccessEntry(input: $input) { - accessEntry { + mutation EntryFlagSelectMutation($input: FlagAccessReviewEntryInput!) { + flagAccessReviewEntry(input: $input) { + accessReviewEntry { id flags flagReasons @@ -38,18 +38,18 @@ const mutation = graphql` type Props = { entryId: string; - currentFlags: readonly AccessEntryFlag[]; + currentFlags: readonly AccessReviewEntryFlag[]; }; export function EntryFlagSelect({ entryId, currentFlags }: Props) { const { __ } = useTranslate(); const { toast } = useToast(); const [open, setOpen] = useState(false); - const [localFlags, setLocalFlags] = useState([...currentFlags]); - const openedWithRef = useRef(currentFlags); + const [localFlags, setLocalFlags] = useState([...currentFlags]); + const openedWithRef = useRef(currentFlags); const [flagEntry] = useMutation(mutation); - const toggleFlag = (flagValue: AccessEntryFlag) => { + const toggleFlag = (flagValue: AccessReviewEntryFlag) => { setLocalFlags(prev => prev.includes(flagValue) ? prev.filter(f => f !== flagValue) @@ -73,7 +73,7 @@ export function EntryFlagSelect({ entryId, currentFlags }: Props) { flagEntry({ variables: { input: { - accessEntryId: entryId, + accessReviewEntryId: entryId, flags: localFlags, }, }, diff --git a/apps/console/src/pages/organizations/access-reviews/_components/accessReviewHelpers.tsx b/apps/console/src/pages/organizations/access-reviews/_components/accessReviewHelpers.tsx index 2a7d8219b..4931113fa 100644 --- a/apps/console/src/pages/organizations/access-reviews/_components/accessReviewHelpers.tsx +++ b/apps/console/src/pages/organizations/access-reviews/_components/accessReviewHelpers.tsx @@ -31,6 +31,21 @@ export function statusBadgeVariant(status: string): BadgeVariant { } } +export function fetchStatusBadgeVariant(status: string): BadgeVariant { + switch (status) { + case "SUCCESS": + return "success"; + case "FAILED": + return "danger"; + case "FETCHING": + return "info"; + case "QUEUED": + return "neutral"; + default: + return "info"; + } +} + export function statusLabel( __: (key: string) => string, status: string, diff --git a/apps/console/src/pages/organizations/access-reviews/campaigns/AccessReviewCampaignsTab.tsx b/apps/console/src/pages/organizations/access-reviews/campaigns/AccessReviewCampaignsTab.tsx index c66fb64c5..18be27c6c 100644 --- a/apps/console/src/pages/organizations/access-reviews/campaigns/AccessReviewCampaignsTab.tsx +++ b/apps/console/src/pages/organizations/access-reviews/campaigns/AccessReviewCampaignsTab.tsx @@ -48,7 +48,7 @@ export const accessReviewCampaignsTabQuery = graphql` organization: node(id: $organizationId) { __typename ... on Organization { - canCreateCampaign: permission(action: "core:access-review-campaign:create") + canCreateCampaign: permission(action: "access-review:campaign:create") ...AccessReviewCampaignsTabFragment } } @@ -78,7 +78,7 @@ const campaignsFragment = graphql` name status createdAt - canDelete: permission(action: "core:access-review-campaign:delete") + canDelete: permission(action: "access-review:campaign:delete") } } } diff --git a/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPage.tsx b/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPage.tsx index 1c5e485a2..77b8e8d07 100644 --- a/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPage.tsx +++ b/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPage.tsx @@ -30,6 +30,7 @@ import { IconPlusLarge, IconRobot, IconTrashCan, + IconWarning, Option, Select, Tbody, @@ -47,8 +48,8 @@ import { type PreloadedQuery, useMutation, usePreloadedQuery, useRelayEnvironmen import { useNavigate } from "react-router"; import { ConnectionHandler, fetchQuery, graphql } from "relay-runtime"; -import type { AccessEntryDecision, CampaignDetailPageBulkDecisionMutation } from "#/__generated__/core/CampaignDetailPageBulkDecisionMutation.graphql"; -import type { AccessEntryFlag, CampaignDetailPageBulkFlagMutation } from "#/__generated__/core/CampaignDetailPageBulkFlagMutation.graphql"; +import type { AccessReviewEntryDecision, CampaignDetailPageBulkDecisionMutation } from "#/__generated__/core/CampaignDetailPageBulkDecisionMutation.graphql"; +import type { AccessReviewEntryFlag, CampaignDetailPageBulkFlagMutation } from "#/__generated__/core/CampaignDetailPageBulkFlagMutation.graphql"; import type { CampaignDetailPageCloseMutation } from "#/__generated__/core/CampaignDetailPageCloseMutation.graphql"; import type { CampaignDetailPageDeleteMutation } from "#/__generated__/core/CampaignDetailPageDeleteMutation.graphql"; import type { CampaignDetailPageQuery } from "#/__generated__/core/CampaignDetailPageQuery.graphql"; @@ -58,6 +59,7 @@ import { useOrganizationId } from "#/hooks/useOrganizationId"; import { decisionBadgeVariant, decisionLabel, + fetchStatusBadgeVariant, flagBadgeVariant, flagGroups, flagLabel, @@ -68,7 +70,7 @@ import { } from "../_components/accessReviewHelpers"; import { EntryDecisionActions } from "../_components/EntryDecisionActions"; import { EntryFlagSelect } from "../_components/EntryFlagSelect"; -import { AddCampaignScopeSourceDialog } from "../dialogs/AddCampaignScopeSourceDialog"; +import { AddCampaignSourceDialog } from "../dialogs/AddCampaignSourceDialog"; const startCampaignMutation = graphql` mutation CampaignDetailPageStartMutation( @@ -111,10 +113,10 @@ const deleteCampaignMutation = graphql` const bulkDecisionMutation = graphql` mutation CampaignDetailPageBulkDecisionMutation( - $input: RecordAccessEntryDecisionsInput! + $input: RecordAccessReviewEntryDecisionsInput! ) { - recordAccessEntryDecisions(input: $input) { - accessEntries { + recordAccessReviewEntryDecisions(input: $input) { + accessReviewEntries { id decision decisionNote @@ -125,10 +127,10 @@ const bulkDecisionMutation = graphql` const bulkFlagMutation = graphql` mutation CampaignDetailPageBulkFlagMutation( - $input: FlagAccessEntryInput! + $input: FlagAccessReviewEntryInput! ) { - flagAccessEntry(input: $input) { - accessEntry { + flagAccessReviewEntry(input: $input) { + accessReviewEntry { id flags flagReasons @@ -145,8 +147,8 @@ export const campaignDetailPageQuery = graphql` id name status - canDelete: permission(action: "core:access-review-campaign:delete") - scopeSources { + canDelete: permission(action: "access-review:campaign:delete") + sources { id source { id @@ -154,6 +156,7 @@ export const campaignDetailPageQuery = graphql` name fetchStatus fetchedAccountsCount + lastError entries(first: 500) { edges { node { @@ -222,9 +225,9 @@ export default function CampaignDetailPage({ queryRef }: Props) { }, 3000); return () => clearInterval(interval); }, [isInProgress, environment]); - const existingScopeSourceIds = useMemo( - () => campaign.scopeSources.flatMap(s => s.source?.id ? [s.source.id] : []), - [campaign.scopeSources], + const existingCampaignSourceIds = useMemo( + () => campaign.sources.flatMap(s => s.source?.id ? [s.source.id] : []), + [campaign.sources], ); const confirm = useConfirm(); @@ -238,8 +241,8 @@ export default function CampaignDetailPage({ queryRef }: Props) { const [deleteCampaign, isDeleting] = useMutation(deleteCampaignMutation); - const allDecided = campaign.scopeSources.length > 0 - && campaign.scopeSources.every(source => + const allDecided = campaign.sources.length > 0 + && campaign.sources.every(source => source.entries && source.entries.edges.length > 0 && source.entries.edges.every(edge => edge.node.decision !== "PENDING") @@ -436,16 +439,16 @@ export default function CampaignDetailPage({ queryRef }: Props) {
{isDraft && (
- - - {campaign.scopeSources.length > 0 && ( + + {campaign.sources.length > 0 && ( + {source.fetchStatus === "FAILED" && source.lastError && ( +
+ +
+

{__("Fetch failed")}

+

{source.lastError}

+
+
+ )} + {expanded && (
{entries.length === 0 @@ -841,7 +857,7 @@ function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; is variables: { input: { decisions: selection.map(id => ({ - accessEntryId: id, + accessReviewEntryId: id, decision: bulkPendingDecision, decisionNote: bulkNote, })), diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessReviewSourceDialog.tsx similarity index 94% rename from apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx rename to apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessReviewSourceDialog.tsx index 6cff5795d..18bf9a620 100644 --- a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessReviewSourceDialog.tsx @@ -37,19 +37,19 @@ import { useMutation } from "react-relay"; import { Link } from "react-router"; import { graphql } from "relay-runtime"; -import type { accessSourceMutationsCreateMutation } from "#/__generated__/core/accessSourceMutationsCreateMutation.graphql"; -import type { AddAccessSourceDialogConnectorProviderInfoFragment$data } from "#/__generated__/core/AddAccessSourceDialogConnectorProviderInfoFragment.graphql"; -import type { AddAccessSourceDialogCreateAPIKeyConnectorMutation } from "#/__generated__/core/AddAccessSourceDialogCreateAPIKeyConnectorMutation.graphql"; -import type { AddAccessSourceDialogCreateClientCredentialsConnectorMutation } from "#/__generated__/core/AddAccessSourceDialogCreateClientCredentialsConnectorMutation.graphql"; +import type { accessReviewSourceMutationsCreateMutation } from "#/__generated__/core/accessReviewSourceMutationsCreateMutation.graphql"; +import type { AddAccessReviewSourceDialogConnectorProviderInfoFragment$data } from "#/__generated__/core/AddAccessReviewSourceDialogConnectorProviderInfoFragment.graphql"; +import type { AddAccessReviewSourceDialogCreateAPIKeyConnectorMutation } from "#/__generated__/core/AddAccessReviewSourceDialogCreateAPIKeyConnectorMutation.graphql"; +import type { AddAccessReviewSourceDialogCreateClientCredentialsConnectorMutation } from "#/__generated__/core/AddAccessReviewSourceDialogCreateClientCredentialsConnectorMutation.graphql"; -import { createAccessSourceMutation } from "./accessSourceMutations"; +import { createAccessReviewSourceMutation } from "./accessReviewSourceMutations"; import { isPostHogDeploymentSelected, PostHogDeploymentField, } from "./PostHogDeploymentField"; -export const addAccessSourceDialogConnectorProviderInfoFragment = graphql` - fragment AddAccessSourceDialogConnectorProviderInfoFragment on ConnectorProviderInfo @relay(plural: true) { +export const addAccessReviewSourceDialogConnectorProviderInfoFragment = graphql` + fragment AddAccessReviewSourceDialogConnectorProviderInfoFragment on ConnectorProviderInfo @relay(plural: true) { provider displayName oauthConfigured @@ -64,7 +64,7 @@ export const addAccessSourceDialogConnectorProviderInfoFragment = graphql` } `; -export type ProviderInfo = AddAccessSourceDialogConnectorProviderInfoFragment$data[number]; +export type ProviderInfo = AddAccessReviewSourceDialogConnectorProviderInfoFragment$data[number]; // DATADOG_SITES labels are technical identifiers (region code + hostname), // intentionally not wrapped in __(). The dialog's prose strings are. @@ -87,7 +87,7 @@ type Props = { }; const createAPIKeyConnectorMutation = graphql` - mutation AddAccessSourceDialogCreateAPIKeyConnectorMutation( + mutation AddAccessReviewSourceDialogCreateAPIKeyConnectorMutation( $input: CreateAPIKeyConnectorInput! ) { createAPIKeyConnector(input: $input) { @@ -100,7 +100,7 @@ const createAPIKeyConnectorMutation = graphql` `; const createClientCredentialsConnectorMutation = graphql` - mutation AddAccessSourceDialogCreateClientCredentialsConnectorMutation( + mutation AddAccessReviewSourceDialogCreateClientCredentialsConnectorMutation( $input: CreateClientCredentialsConnectorInput! ) { createClientCredentialsConnector(input: $input) { @@ -199,7 +199,7 @@ function cleanZendeskSubdomain(raw: string): string { return value.trim(); } -export function AddAccessSourceDialog({ +export function AddAccessReviewSourceDialog({ children, organizationId, connectionId, @@ -250,16 +250,16 @@ export function AddAccessSourceDialog({ [existingSourceProviders], ); - const [createAccessSource] - = useMutation( - createAccessSourceMutation, + const [createAccessReviewSource] + = useMutation( + createAccessReviewSourceMutation, ); const [createAPIKeyConnector] - = useMutation( + = useMutation( createAPIKeyConnectorMutation, ); const [createClientCredentialsConnector] - = useMutation( + = useMutation( createClientCredentialsConnectorMutation, ); @@ -320,7 +320,7 @@ export function AddAccessSourceDialog({ displayName: string, onDone: () => void, ) => { - createAccessSource({ + createAccessReviewSource({ variables: { input: { organizationId, diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/AddCampaignScopeSourceDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/AddCampaignSourceDialog.tsx similarity index 81% rename from apps/console/src/pages/organizations/access-reviews/dialogs/AddCampaignScopeSourceDialog.tsx rename to apps/console/src/pages/organizations/access-reviews/dialogs/AddCampaignSourceDialog.tsx index 452440d0b..752cf33fa 100644 --- a/apps/console/src/pages/organizations/access-reviews/dialogs/AddCampaignScopeSourceDialog.tsx +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/AddCampaignSourceDialog.tsx @@ -28,17 +28,17 @@ import { import { type ReactNode, Suspense, useState } from "react"; import { graphql, useLazyLoadQuery, useMutation } from "react-relay"; -import type { AddCampaignScopeSourceDialogMutation } from "#/__generated__/core/AddCampaignScopeSourceDialogMutation.graphql"; -import type { AddCampaignScopeSourceDialogSourcesQuery } from "#/__generated__/core/AddCampaignScopeSourceDialogSourcesQuery.graphql"; +import type { AddCampaignSourceDialogMutation } from "#/__generated__/core/AddCampaignSourceDialogMutation.graphql"; +import type { AddCampaignSourceDialogSourcesQuery } from "#/__generated__/core/AddCampaignSourceDialogSourcesQuery.graphql"; const addScopeMutation = graphql` - mutation AddCampaignScopeSourceDialogMutation( - $input: AddAccessReviewCampaignScopeSourceInput! + mutation AddCampaignSourceDialogMutation( + $input: AddAccessReviewCampaignSourceInput! ) { - addAccessReviewCampaignScopeSource(input: $input) { + addAccessReviewCampaignSource(input: $input) { accessReviewCampaign { id - scopeSources { + sources { id name fetchStatus @@ -68,10 +68,10 @@ const addScopeMutation = graphql` `; const sourcesQuery = graphql` - query AddCampaignScopeSourceDialogSourcesQuery($organizationId: ID!) { + query AddCampaignSourceDialogSourcesQuery($organizationId: ID!) { organization: node(id: $organizationId) { ... on Organization { - accessSources(first: 100) { + accessReviewSources(first: 100) { edges { node { id @@ -88,31 +88,31 @@ type Props = { children: ReactNode; organizationId: string; campaignId: string; - existingScopeSourceIds: string[]; + existingCampaignSourceIds: string[]; }; -export function AddCampaignScopeSourceDialog({ +export function AddCampaignSourceDialog({ children, organizationId, campaignId, - existingScopeSourceIds, + existingCampaignSourceIds, }: Props) { const { __ } = useTranslate(); const { toast } = useToast(); const ref = useDialogRef(); const [selectedSourceId, setSelectedSourceId] = useState(""); - const [addScopeSource, isAdding] - = useMutation(addScopeMutation); + const [addCampaignSource, isAdding] + = useMutation(addScopeMutation); const onSubmit = () => { if (!selectedSourceId) return; - addScopeSource({ + addCampaignSource({ variables: { input: { accessReviewCampaignId: campaignId, - accessSourceId: selectedSourceId, + accessReviewSourceId: selectedSourceId, }, }, onCompleted(_, errors) { @@ -164,7 +164,7 @@ export function AddCampaignScopeSourceDialog({ > @@ -184,29 +184,29 @@ export function AddCampaignScopeSourceDialog({ function SourceSelect({ organizationId, - existingScopeSourceIds, + existingCampaignSourceIds, value, onChange, }: { organizationId: string; - existingScopeSourceIds: string[]; + existingCampaignSourceIds: string[]; value: string; onChange: (value: string) => void; }) { const { __ } = useTranslate(); const data - = useLazyLoadQuery( + = useLazyLoadQuery( sourcesQuery, { organizationId }, { fetchPolicy: "network-only" }, ); const sources - = data?.organization?.accessSources?.edges + = data?.organization?.accessReviewSources?.edges ?.map(edge => edge.node) .filter( (node): node is NonNullable => - node !== null && !existingScopeSourceIds.includes(node.id), + node !== null && !existingCampaignSourceIds.includes(node.id), ) ?? []; if (sources.length === 0) { diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/CreateAccessReviewCampaignDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/CreateAccessReviewCampaignDialog.tsx index f66874d45..62973ed34 100644 --- a/apps/console/src/pages/organizations/access-reviews/dialogs/CreateAccessReviewCampaignDialog.tsx +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/CreateAccessReviewCampaignDialog.tsx @@ -55,7 +55,7 @@ const sourcesQuery = graphql` query CreateAccessReviewCampaignDialogSourcesQuery($organizationId: ID!) { organization: node(id: $organizationId) { ... on Organization { - accessSources(first: 500) { + accessReviewSources(first: 500) { edges { node { id @@ -118,7 +118,7 @@ export function CreateAccessReviewCampaignDialog({ organizationId, name: data.name, description: data.description || null, - accessSourceIds: + accessReviewSourceIds: selectedSourceIds.length > 0 ? selectedSourceIds : null, }, connections: [connectionId], @@ -227,7 +227,7 @@ function SourceSelector({ ); const sources - = data?.organization?.accessSources?.edges + = data?.organization?.accessReviewSources?.edges ?.map(edge => edge.node) .filter((node): node is NonNullable => node !== null) ?? []; diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/accessSourceMutations.ts b/apps/console/src/pages/organizations/access-reviews/dialogs/accessReviewSourceMutations.ts similarity index 75% rename from apps/console/src/pages/organizations/access-reviews/dialogs/accessSourceMutations.ts rename to apps/console/src/pages/organizations/access-reviews/dialogs/accessReviewSourceMutations.ts index 4e4b59c73..2d670355a 100644 --- a/apps/console/src/pages/organizations/access-reviews/dialogs/accessSourceMutations.ts +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/accessReviewSourceMutations.ts @@ -14,18 +14,18 @@ import { graphql } from "relay-runtime"; -export const createAccessSourceMutation = graphql` - mutation accessSourceMutationsCreateMutation( - $input: CreateAccessSourceInput! +export const createAccessReviewSourceMutation = graphql` + mutation accessReviewSourceMutationsCreateMutation( + $input: CreateAccessReviewSourceInput! $connections: [ID!]! ) { - createAccessSource(input: $input) { - accessSourceEdge @prependEdge(connections: $connections) { + createAccessReviewSource(input: $input) { + accessReviewSourceEdge @prependEdge(connections: $connections) { node { id name createdAt - ...AccessSourceRowFragment + ...AccessReviewSourceRowFragment } } } diff --git a/apps/console/src/pages/organizations/access-reviews/sources/AccessReviewSourcesTab.tsx b/apps/console/src/pages/organizations/access-reviews/sources/AccessReviewSourcesTab.tsx index f2fa3271a..2026f617b 100644 --- a/apps/console/src/pages/organizations/access-reviews/sources/AccessReviewSourcesTab.tsx +++ b/apps/console/src/pages/organizations/access-reviews/sources/AccessReviewSourcesTab.tsx @@ -33,23 +33,23 @@ import { useSearchParams } from "react-router"; import type { AccessReviewSourcesTabFragment$key } from "#/__generated__/core/AccessReviewSourcesTabFragment.graphql"; import type { AccessReviewSourcesTabPaginationQuery } from "#/__generated__/core/AccessReviewSourcesTabPaginationQuery.graphql"; import type { AccessReviewSourcesTabQuery } from "#/__generated__/core/AccessReviewSourcesTabQuery.graphql"; -import type { accessSourceMutationsCreateMutation } from "#/__generated__/core/accessSourceMutationsCreateMutation.graphql"; -import type { AddAccessSourceDialogConnectorProviderInfoFragment$key } from "#/__generated__/core/AddAccessSourceDialogConnectorProviderInfoFragment.graphql"; +import type { accessReviewSourceMutationsCreateMutation } from "#/__generated__/core/accessReviewSourceMutationsCreateMutation.graphql"; +import type { AddAccessReviewSourceDialogConnectorProviderInfoFragment$key } from "#/__generated__/core/AddAccessReviewSourceDialogConnectorProviderInfoFragment.graphql"; import { useOrganizationId } from "#/hooks/useOrganizationId"; -import { AccessSourceRow } from "../_components/AccessSourceRow"; -import { createAccessSourceMutation } from "../dialogs/accessSourceMutations"; -import { AddAccessSourceDialog, addAccessSourceDialogConnectorProviderInfoFragment } from "../dialogs/AddAccessSourceDialog"; +import { AccessReviewSourceRow } from "../_components/AccessReviewSourceRow"; +import { createAccessReviewSourceMutation } from "../dialogs/accessReviewSourceMutations"; +import { AddAccessReviewSourceDialog, addAccessReviewSourceDialogConnectorProviderInfoFragment } from "../dialogs/AddAccessReviewSourceDialog"; export const accessReviewSourcesTabQuery = graphql` query AccessReviewSourcesTabQuery($organizationId: ID!) { accessReviewDrivers { - ...AddAccessSourceDialogConnectorProviderInfoFragment + ...AddAccessReviewSourceDialogConnectorProviderInfoFragment } organization: node(id: $organizationId) { __typename ... on Organization { - canCreateSource: permission(action: "core:access-source:create") + canCreateSource: permission(action: "access-review:source:create") ...AccessReviewSourcesTabFragment } } @@ -62,20 +62,20 @@ const sourcesFragment = graphql` @argumentDefinitions( first: { type: "Int", defaultValue: 50 } order: { - type: "AccessSourceOrder" + type: "AccessReviewSourceOrder" defaultValue: { direction: DESC, field: CREATED_AT } } after: { type: "CursorKey", defaultValue: null } before: { type: "CursorKey", defaultValue: null } last: { type: "Int", defaultValue: null } ) { - accessSources( + accessReviewSources( first: $first after: $after last: $last before: $before orderBy: $order - ) @connection(key: "AccessReviewSourcesTab_accessSources") { + ) @connection(key: "AccessReviewSourcesTab_accessReviewSources") { __id edges { node { @@ -84,7 +84,7 @@ const sourcesFragment = graphql` connector { provider } - ...AccessSourceRowFragment + ...AccessReviewSourceRowFragment } } } @@ -107,13 +107,13 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) { throw new Error("Organization not found"); } - const connectorProviderInfos = useFragment( - addAccessSourceDialogConnectorProviderInfoFragment, + const connectorProviderInfos = useFragment( + addAccessReviewSourceDialogConnectorProviderInfoFragment, accessReviewDrivers, ); const { - data: { accessSources }, + data: { accessReviewSources }, loadNext, hasNext, isLoadingNext, @@ -124,15 +124,15 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) { const existingSourceProviders = useMemo( () => - accessSources.edges + accessReviewSources.edges .map(edge => edge.node.connector?.provider) .filter((p): p is NonNullable => p != null), - [accessSources.edges], + [accessReviewSources.edges], ); - const [createAccessSource, isCreatingSource] - = useMutation( - createAccessSourceMutation, + const [createAccessReviewSource, isCreatingSource] + = useMutation( + createAccessReviewSourceMutation, ); // Handle OAuth callback: after the provider redirects back with connector_id, @@ -140,7 +140,7 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) { const callbackConnectorId = searchParams.get("connector_id"); const callbackProvider = searchParams.get("provider"); const hasSourceForCallback = !!callbackConnectorId - && accessSources?.edges.some(edge => edge.node.connectorId === callbackConnectorId); + && accessReviewSources?.edges.some(edge => edge.node.connectorId === callbackConnectorId); useEffect(() => { if (!callbackConnectorId) return; @@ -164,7 +164,7 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) { : null; const sourceName = providerInfo?.displayName ?? callbackProvider ?? "Source"; - createAccessSource({ + createAccessReviewSource({ variables: { input: { organizationId, @@ -172,7 +172,7 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) { name: sourceName, csvData: null, }, - connections: [accessSources.__id], + connections: [accessReviewSources.__id], }, onCompleted(_, errors) { if (errors?.length) { @@ -225,11 +225,11 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) { callbackConnectorId, callbackProvider, connectorProviderInfos, - createAccessSource, + createAccessReviewSource, hasSourceForCallback, isCreatingSource, organizationId, - accessSources.__id, + accessReviewSources.__id, setSearchParams, toast, ]); @@ -238,20 +238,20 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
{organization.canCreateSource && ( - - + )}
- {accessSources && accessSources.edges.length > 0 + {accessReviewSources && accessReviewSources.edges.length > 0 ? ( @@ -266,11 +266,11 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) { - {accessSources.edges.map(edge => ( - ( + ))} diff --git a/apps/console/src/routes/accessReviewRoutes.ts b/apps/console/src/routes/accessReviewRoutes.ts index 2e69fbf5c..c35e1cdb3 100644 --- a/apps/console/src/routes/accessReviewRoutes.ts +++ b/apps/console/src/routes/accessReviewRoutes.ts @@ -38,7 +38,7 @@ export const accessReviewRoutes = [ path: "access-reviews/sources/new/csv", Fallback: PageSkeleton, Component: lazy( - () => import("#/pages/organizations/access-reviews/CreateCsvAccessSourcePageLoader"), + () => import("#/pages/organizations/access-reviews/CreateCsvAccessReviewSourcePageLoader"), ), }, ] satisfies AppRoute[]; diff --git a/contrib/claude/authorization.md b/contrib/claude/authorization.md index ee4468ee4..795e84917 100644 --- a/contrib/claude/authorization.md +++ b/contrib/claude/authorization.md @@ -251,6 +251,7 @@ and `r.AuthorizeBatch` (MCP) — keep the returned scope and pass it down. | Product action constants (`core:*`) | `pkg/probo/actions.go` | | IAM action constants (`iam:*`) | `pkg/iam/iam_actions.go` | | Product role policies (`ProboPolicySet`) | `pkg/probo/policies.go` | +| Per-service policy sets (e.g. `accessreview.PolicySet`, `agentrun.PolicySet`) | `pkg//actions.go`, `pkg//policies.go` | | IAM role policies (`IAMPolicySet`) | `pkg/iam/iam_policies.go` | | Authorizer + `AuthorizationAttributer` | `pkg/iam/authorizer.go` | | PolicySet registration | `pkg/iam/policy_set.go` | diff --git a/e2e/console/access_review_test.go b/e2e/console/access_review_test.go index 8fba873a7..2f0bb808f 100644 --- a/e2e/console/access_review_test.go +++ b/e2e/console/access_review_test.go @@ -26,7 +26,7 @@ import ( const testCsvData = "email,full_name,role,job_title,is_admin,active,mfa_status,auth_method,last_login,account_created_at,external_id\njane@example.com,Jane Smith,admin,CTO,true,true,ENABLED,SSO,2026-01-15T00:00:00Z,2024-06-01T00:00:00Z,ext-jane" -func TestAccessSource_Create(t *testing.T) { +func TestAccessReviewSource_Create(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) orgID := owner.GetOrganizationID().String() @@ -35,9 +35,9 @@ func TestAccessSource_Create(t *testing.T) { t.Parallel() const query = ` - mutation($input: CreateAccessSourceInput!) { - createAccessSource(input: $input) { - accessSourceEdge { + mutation($input: CreateAccessReviewSourceInput!) { + createAccessReviewSource(input: $input) { + accessReviewSourceEdge { node { id name @@ -50,16 +50,16 @@ func TestAccessSource_Create(t *testing.T) { ` var result struct { - CreateAccessSource struct { - AccessSourceEdge struct { + CreateAccessReviewSource struct { + AccessReviewSourceEdge struct { Node struct { ID string `json:"id"` Name string `json:"name"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } `json:"node"` - } `json:"accessSourceEdge"` - } `json:"createAccessSource"` + } `json:"accessReviewSourceEdge"` + } `json:"createAccessReviewSource"` } err := owner.Execute(query, map[string]any{ @@ -70,7 +70,7 @@ func TestAccessSource_Create(t *testing.T) { }, &result) require.NoError(t, err) - node := result.CreateAccessSource.AccessSourceEdge.Node + node := result.CreateAccessReviewSource.AccessReviewSourceEdge.Node assert.NotEmpty(t, node.ID) assert.Equal(t, "Slack", node.Name) assert.NotEmpty(t, node.CreatedAt) @@ -80,9 +80,9 @@ func TestAccessSource_Create(t *testing.T) { t.Parallel() const query = ` - mutation($input: CreateAccessSourceInput!) { - createAccessSource(input: $input) { - accessSourceEdge { + mutation($input: CreateAccessReviewSourceInput!) { + createAccessReviewSource(input: $input) { + accessReviewSourceEdge { node { id name @@ -94,15 +94,15 @@ func TestAccessSource_Create(t *testing.T) { ` var result struct { - CreateAccessSource struct { - AccessSourceEdge struct { + CreateAccessReviewSource struct { + AccessReviewSourceEdge struct { Node struct { ID string `json:"id"` Name string `json:"name"` CsvData *string `json:"csvData"` } `json:"node"` - } `json:"accessSourceEdge"` - } `json:"createAccessSource"` + } `json:"accessReviewSourceEdge"` + } `json:"createAccessReviewSource"` } err := owner.Execute(query, map[string]any{ @@ -114,7 +114,7 @@ func TestAccessSource_Create(t *testing.T) { }, &result) require.NoError(t, err) - node := result.CreateAccessSource.AccessSourceEdge.Node + node := result.CreateAccessReviewSource.AccessReviewSourceEdge.Node assert.NotEmpty(t, node.ID) assert.Equal(t, "CSV Import", node.Name) require.NotNil(t, node.CsvData) @@ -122,18 +122,18 @@ func TestAccessSource_Create(t *testing.T) { }) } -func TestAccessSource_Update(t *testing.T) { +func TestAccessReviewSource_Update(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) orgID := owner.GetOrganizationID().String() - sourceID := factory.NewAccessSource(owner, orgID). + sourceID := factory.NewAccessReviewSource(owner, orgID). WithName("Original Source"). Create() const query = ` - mutation($input: UpdateAccessSourceInput!) { - updateAccessSource(input: $input) { - accessSource { + mutation($input: UpdateAccessReviewSourceInput!) { + updateAccessReviewSource(input: $input) { + accessReviewSource { id name } @@ -142,71 +142,71 @@ func TestAccessSource_Update(t *testing.T) { ` var result struct { - UpdateAccessSource struct { - AccessSource struct { + UpdateAccessReviewSource struct { + AccessReviewSource struct { ID string `json:"id"` Name string `json:"name"` - } `json:"accessSource"` - } `json:"updateAccessSource"` + } `json:"accessReviewSource"` + } `json:"updateAccessReviewSource"` } err := owner.Execute(query, map[string]any{ "input": map[string]any{ - "accessSourceId": sourceID, - "name": "Updated Source", + "accessReviewSourceId": sourceID, + "name": "Updated Source", }, }, &result) require.NoError(t, err) - assert.Equal(t, sourceID, result.UpdateAccessSource.AccessSource.ID) - assert.Equal(t, "Updated Source", result.UpdateAccessSource.AccessSource.Name) + assert.Equal(t, sourceID, result.UpdateAccessReviewSource.AccessReviewSource.ID) + assert.Equal(t, "Updated Source", result.UpdateAccessReviewSource.AccessReviewSource.Name) } -func TestAccessSource_Delete(t *testing.T) { +func TestAccessReviewSource_Delete(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) orgID := owner.GetOrganizationID().String() - sourceID := factory.NewAccessSource(owner, orgID). + sourceID := factory.NewAccessReviewSource(owner, orgID). WithName("Source to Delete"). Create() const query = ` - mutation($input: DeleteAccessSourceInput!) { - deleteAccessSource(input: $input) { - deletedAccessSourceId + mutation($input: DeleteAccessReviewSourceInput!) { + deleteAccessReviewSource(input: $input) { + deletedAccessReviewSourceId } } ` var result struct { - DeleteAccessSource struct { - DeletedAccessSourceID string `json:"deletedAccessSourceId"` - } `json:"deleteAccessSource"` + DeleteAccessReviewSource struct { + DeletedAccessReviewSourceID string `json:"deletedAccessReviewSourceId"` + } `json:"deleteAccessReviewSource"` } err := owner.Execute(query, map[string]any{ "input": map[string]any{ - "accessSourceId": sourceID, + "accessReviewSourceId": sourceID, }, }, &result) require.NoError(t, err) - assert.Equal(t, sourceID, result.DeleteAccessSource.DeletedAccessSourceID) + assert.Equal(t, sourceID, result.DeleteAccessReviewSource.DeletedAccessReviewSourceID) } -func TestAccessSource_List(t *testing.T) { +func TestAccessReviewSource_List(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) orgID := owner.GetOrganizationID().String() for _, name := range []string{"Slack", "GitHub", "Google Workspace"} { - factory.NewAccessSource(owner, orgID).WithName(name).Create() + factory.NewAccessReviewSource(owner, orgID).WithName(name).Create() } const query = ` query($id: ID!) { node(id: $id) { ... on Organization { - accessSources(first: 10) { + accessReviewSources(first: 10) { edges { node { id @@ -222,7 +222,7 @@ func TestAccessSource_List(t *testing.T) { var result struct { Node struct { - AccessSources struct { + AccessReviewSources struct { Edges []struct { Node struct { ID string `json:"id"` @@ -230,13 +230,13 @@ func TestAccessSource_List(t *testing.T) { } `json:"node"` } `json:"edges"` TotalCount int `json:"totalCount"` - } `json:"accessSources"` + } `json:"accessReviewSources"` } `json:"node"` } err := owner.Execute(query, map[string]any{"id": orgID}, &result) require.NoError(t, err) - assert.GreaterOrEqual(t, result.Node.AccessSources.TotalCount, 3) + assert.GreaterOrEqual(t, result.Node.AccessReviewSources.TotalCount, 3) } func TestAccessReviewCampaign_Create(t *testing.T) { @@ -295,10 +295,10 @@ func TestAccessReviewCampaign_Create(t *testing.T) { t.Run("with access sources", func(t *testing.T) { t.Parallel() - source1ID := factory.NewAccessSource(owner, orgID). + source1ID := factory.NewAccessReviewSource(owner, orgID). WithName("Slack Source"). Create() - source2ID := factory.NewAccessSource(owner, orgID). + source2ID := factory.NewAccessReviewSource(owner, orgID). WithName("GitHub Source"). Create() @@ -309,7 +309,7 @@ func TestAccessReviewCampaign_Create(t *testing.T) { node { id name - scopeSources { + sources { id name } @@ -323,12 +323,12 @@ func TestAccessReviewCampaign_Create(t *testing.T) { CreateAccessReviewCampaign struct { AccessReviewCampaignEdge struct { Node struct { - ID string `json:"id"` - Name string `json:"name"` - ScopeSources []struct { + ID string `json:"id"` + Name string `json:"name"` + CampaignSources []struct { ID string `json:"id"` Name string `json:"name"` - } `json:"scopeSources"` + } `json:"sources"` } `json:"node"` } `json:"accessReviewCampaignEdge"` } `json:"createAccessReviewCampaign"` @@ -336,9 +336,9 @@ func TestAccessReviewCampaign_Create(t *testing.T) { err := owner.Execute(query, map[string]any{ "input": map[string]any{ - "organizationId": orgID, - "name": "Campaign with Sources", - "accessSourceIds": []string{source1ID, source2ID}, + "organizationId": orgID, + "name": "Campaign with Sources", + "accessReviewSourceIds": []string{source1ID, source2ID}, }, }, &result) require.NoError(t, err) @@ -346,7 +346,7 @@ func TestAccessReviewCampaign_Create(t *testing.T) { node := result.CreateAccessReviewCampaign.AccessReviewCampaignEdge.Node assert.NotEmpty(t, node.ID) assert.Equal(t, "Campaign with Sources", node.Name) - assert.Len(t, node.ScopeSources, 2) + assert.Len(t, node.CampaignSources, 2) }) t.Run("with framework controls", func(t *testing.T) { @@ -478,13 +478,13 @@ func TestAccessReviewCampaign_DeleteRemovesFromListAndNode(t *testing.T) { owner := testutil.NewClient(t, testutil.RoleOwner) orgID := owner.GetOrganizationID().String() - sourceID := factory.NewAccessSource(owner, orgID). + sourceID := factory.NewAccessReviewSource(owner, orgID). WithName("Source for Delete"). WithCsvData(testCsvData). Create() campaignID := factory.NewAccessReviewCampaign(owner, orgID). WithName("Campaign to Cascade Delete"). - WithAccessSourceIDs([]string{sourceID}). + WithAccessReviewSourceIDs([]string{sourceID}). Create() const deleteMutation = ` @@ -676,14 +676,14 @@ func TestAccessReviewCampaign_StartWithCsvSource(t *testing.T) { owner := testutil.NewClient(t, testutil.RoleOwner) orgID := owner.GetOrganizationID().String() - sourceID := factory.NewAccessSource(owner, orgID). + sourceID := factory.NewAccessReviewSource(owner, orgID). WithName("CSV Test Source"). WithCsvData(testCsvData). Create() campaignID := factory.NewAccessReviewCampaign(owner, orgID). WithName("CSV Campaign"). - WithAccessSourceIDs([]string{sourceID}). + WithAccessReviewSourceIDs([]string{sourceID}). Create() const query = ` @@ -721,12 +721,12 @@ func TestAccessReviewCampaign_StartWithCsvSource(t *testing.T) { assert.NotNil(t, campaign.StartedAt) } -func TestAccessReviewCampaign_AddAndRemoveScopeSource(t *testing.T) { +func TestAccessReviewCampaign_AddAndRemoveCampaignSource(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) orgID := owner.GetOrganizationID().String() - sourceID := factory.NewAccessSource(owner, orgID). + sourceID := factory.NewAccessReviewSource(owner, orgID). WithName("Scope Source"). Create() @@ -736,13 +736,16 @@ func TestAccessReviewCampaign_AddAndRemoveScopeSource(t *testing.T) { t.Run("add scope source", func(t *testing.T) { const query = ` - mutation($input: AddAccessReviewCampaignScopeSourceInput!) { - addAccessReviewCampaignScopeSource(input: $input) { + mutation($input: AddAccessReviewCampaignSourceInput!) { + addAccessReviewCampaignSource(input: $input) { accessReviewCampaign { id - scopeSources { + sources { id name + source { + id + } } } } @@ -750,38 +753,45 @@ func TestAccessReviewCampaign_AddAndRemoveScopeSource(t *testing.T) { ` var result struct { - AddAccessReviewCampaignScopeSource struct { + AddAccessReviewCampaignSource struct { AccessReviewCampaign struct { - ID string `json:"id"` - ScopeSources []struct { - ID string `json:"id"` - Name string `json:"name"` - } `json:"scopeSources"` + ID string `json:"id"` + CampaignSources []struct { + ID string `json:"id"` + Name string `json:"name"` + Source *struct { + ID string `json:"id"` + } `json:"source"` + } `json:"sources"` } `json:"accessReviewCampaign"` - } `json:"addAccessReviewCampaignScopeSource"` + } `json:"addAccessReviewCampaignSource"` } err := owner.Execute(query, map[string]any{ "input": map[string]any{ "accessReviewCampaignId": campaignID, - "accessSourceId": sourceID, + "accessReviewSourceId": sourceID, }, }, &result) require.NoError(t, err) - campaign := result.AddAccessReviewCampaignScopeSource.AccessReviewCampaign + campaign := result.AddAccessReviewCampaignSource.AccessReviewCampaign assert.Equal(t, campaignID, campaign.ID) - assert.Len(t, campaign.ScopeSources, 1) - assert.Equal(t, sourceID, campaign.ScopeSources[0].ID) + assert.Len(t, campaign.CampaignSources, 1) + // The scope source id is now a per-campaign snapshot id, distinct from + // the live source id, which is exposed via the source link. + assert.NotEqual(t, sourceID, campaign.CampaignSources[0].ID) + require.NotNil(t, campaign.CampaignSources[0].Source) + assert.Equal(t, sourceID, campaign.CampaignSources[0].Source.ID) }) t.Run("remove scope source", func(t *testing.T) { const query = ` - mutation($input: RemoveAccessReviewCampaignScopeSourceInput!) { - removeAccessReviewCampaignScopeSource(input: $input) { + mutation($input: RemoveAccessReviewCampaignSourceInput!) { + removeAccessReviewCampaignSource(input: $input) { accessReviewCampaign { id - scopeSources { + sources { id } } @@ -790,27 +800,27 @@ func TestAccessReviewCampaign_AddAndRemoveScopeSource(t *testing.T) { ` var result struct { - RemoveAccessReviewCampaignScopeSource struct { + RemoveAccessReviewCampaignSource struct { AccessReviewCampaign struct { - ID string `json:"id"` - ScopeSources []struct { + ID string `json:"id"` + CampaignSources []struct { ID string `json:"id"` - } `json:"scopeSources"` + } `json:"sources"` } `json:"accessReviewCampaign"` - } `json:"removeAccessReviewCampaignScopeSource"` + } `json:"removeAccessReviewCampaignSource"` } err := owner.Execute(query, map[string]any{ "input": map[string]any{ "accessReviewCampaignId": campaignID, - "accessSourceId": sourceID, + "accessReviewSourceId": sourceID, }, }, &result) require.NoError(t, err) - campaign := result.RemoveAccessReviewCampaignScopeSource.AccessReviewCampaign + campaign := result.RemoveAccessReviewCampaignSource.AccessReviewCampaign assert.Equal(t, campaignID, campaign.ID) - assert.Empty(t, campaign.ScopeSources) + assert.Empty(t, campaign.CampaignSources) }) } @@ -819,14 +829,14 @@ func TestAccessReviewCampaign_Cancel(t *testing.T) { owner := testutil.NewClient(t, testutil.RoleOwner) orgID := owner.GetOrganizationID().String() - sourceID := factory.NewAccessSource(owner, orgID). + sourceID := factory.NewAccessReviewSource(owner, orgID). WithName("Cancel Test Source"). WithCsvData(testCsvData). Create() campaignID := factory.NewAccessReviewCampaign(owner, orgID). WithName("Campaign to Cancel"). - WithAccessSourceIDs([]string{sourceID}). + WithAccessReviewSourceIDs([]string{sourceID}). Create() // Start the campaign first @@ -971,7 +981,7 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) { orgID := owner.GetOrganizationID().String() // Step 1: Create a CSV source with test data - sourceID := factory.NewAccessSource(owner, orgID). + sourceID := factory.NewAccessReviewSource(owner, orgID). WithName("Lifecycle Test Source"). WithCsvData(testCsvData). Create() @@ -986,7 +996,7 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) { name description status - scopeSources { + sources { id } } @@ -999,13 +1009,13 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) { CreateAccessReviewCampaign struct { AccessReviewCampaignEdge struct { Node struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Status string `json:"status"` - ScopeSources []struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + CampaignSources []struct { ID string `json:"id"` - } `json:"scopeSources"` + } `json:"sources"` } `json:"node"` } `json:"accessReviewCampaignEdge"` } `json:"createAccessReviewCampaign"` @@ -1013,10 +1023,10 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) { err := owner.Execute(createQuery, map[string]any{ "input": map[string]any{ - "organizationId": orgID, - "name": "Full Lifecycle Campaign", - "description": "Testing the full lifecycle", - "accessSourceIds": []string{sourceID}, + "organizationId": orgID, + "name": "Full Lifecycle Campaign", + "description": "Testing the full lifecycle", + "accessReviewSourceIds": []string{sourceID}, }, }, &createResult) require.NoError(t, err) @@ -1025,7 +1035,7 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) { campaignID := campaignNode.ID assert.Equal(t, "DRAFT", campaignNode.Status) assert.Equal(t, "Testing the full lifecycle", campaignNode.Description) - assert.Len(t, campaignNode.ScopeSources, 1) + assert.Len(t, campaignNode.CampaignSources, 1) // Step 3: Start the campaign (triggers worker to fetch CSV data) const startQuery = ` @@ -1144,8 +1154,8 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) { // Step 5: Record decisions on all entries const recordDecisionQuery = ` - mutation($input: RecordAccessEntryDecisionInput!) { - recordAccessEntryDecision(input: $input) { + mutation($input: RecordAccessReviewEntryDecisionInput!) { + recordAccessReviewEntryDecision(input: $input) { accessEntry { id decision @@ -1162,8 +1172,8 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) { for _, edge := range campaignResult.Node.Entries.Edges { var decisionResult struct { - RecordAccessEntryDecision struct { - AccessEntry struct { + RecordAccessReviewEntryDecision struct { + AccessReviewEntry struct { ID string `json:"id"` Decision string `json:"decision"` DecidedAt *string `json:"decidedAt"` @@ -1172,18 +1182,18 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) { Decision string `json:"decision"` } `json:"decisionHistory"` } `json:"accessEntry"` - } `json:"recordAccessEntryDecision"` + } `json:"recordAccessReviewEntryDecision"` } err = owner.Execute(recordDecisionQuery, map[string]any{ "input": map[string]any{ - "accessEntryId": edge.Node.ID, - "decision": "APPROVED", + "accessReviewEntryId": edge.Node.ID, + "decision": "APPROVED", }, }, &decisionResult) require.NoError(t, err) - entry := decisionResult.RecordAccessEntryDecision.AccessEntry + entry := decisionResult.RecordAccessReviewEntryDecision.AccessReviewEntry assert.Equal(t, "APPROVED", entry.Decision) assert.NotNil(t, entry.DecidedAt) @@ -1232,14 +1242,14 @@ func TestAccessReviewCampaign_CloseRequiresAllDecisions(t *testing.T) { owner := testutil.NewClient(t, testutil.RoleOwner) orgID := owner.GetOrganizationID().String() - sourceID := factory.NewAccessSource(owner, orgID). + sourceID := factory.NewAccessReviewSource(owner, orgID). WithName("Close Guard Source"). WithCsvData(testCsvData). Create() campaignID := factory.NewAccessReviewCampaign(owner, orgID). WithName("Close Guard Campaign"). - WithAccessSourceIDs([]string{sourceID}). + WithAccessReviewSourceIDs([]string{sourceID}). Create() // Start the campaign @@ -1334,9 +1344,9 @@ func TestAccessReview_TenantIsolation(t *testing.T) { t.Parallel() const query = ` - mutation($input: CreateAccessSourceInput!) { - createAccessSource(input: $input) { - accessSourceEdge { + mutation($input: CreateAccessReviewSourceInput!) { + createAccessReviewSource(input: $input) { + accessReviewSourceEdge { node { id } } } diff --git a/e2e/console/rbac_test.go b/e2e/console/rbac_test.go index ebf633d13..0e2287e31 100644 --- a/e2e/console/rbac_test.go +++ b/e2e/console/rbac_test.go @@ -204,32 +204,32 @@ const ( } }` - createAccessSourceMutation = ` - mutation CreateAccessSource($input: CreateAccessSourceInput!) { - createAccessSource(input: $input) { - accessSourceEdge { node { id } } + createAccessReviewSourceMutation = ` + mutation CreateAccessReviewSource($input: CreateAccessReviewSourceInput!) { + createAccessReviewSource(input: $input) { + accessReviewSourceEdge { node { id } } } }` - updateAccessSourceMutation = ` - mutation UpdateAccessSource($input: UpdateAccessSourceInput!) { - updateAccessSource(input: $input) { - accessSource { id } + updateAccessReviewSourceMutation = ` + mutation UpdateAccessReviewSource($input: UpdateAccessReviewSourceInput!) { + updateAccessReviewSource(input: $input) { + accessReviewSource { id } } }` - deleteAccessSourceMutation = ` - mutation DeleteAccessSource($input: DeleteAccessSourceInput!) { - deleteAccessSource(input: $input) { - deletedAccessSourceId + deleteAccessReviewSourceMutation = ` + mutation DeleteAccessReviewSource($input: DeleteAccessReviewSourceInput!) { + deleteAccessReviewSource(input: $input) { + deletedAccessReviewSourceId } }` - listAccessSourcesQuery = ` - query GetAccessSources($id: ID!) { + listAccessReviewSourcesQuery = ` + query GetAccessReviewSources($id: ID!) { node(id: $id) { ... on Organization { - accessSources(first: 10) { totalCount } + accessReviewSources(first: 10) { totalCount } } } }` @@ -305,7 +305,7 @@ func TestRBAC(t *testing.T) { taskID := factory.NewTask(owner, measureID).WithName("RBAC Test Task").Create() riskID := factory.NewRisk(owner).WithName("RBAC Test Risk").Create() thirdPartyID := factory.NewThirdParty(owner).WithName("RBAC Test ThirdParty").Create() - accessSourceID := factory.NewAccessSource(owner, owner.GetOrganizationID().String()).WithName("RBAC Test Source").Create() + accessReviewSourceID := factory.NewAccessReviewSource(owner, owner.GetOrganizationID().String()).WithName("RBAC Test Source").Create() accessReviewCampaignID := factory.NewAccessReviewCampaign(owner, owner.GetOrganizationID().String()).WithName("RBAC Test Campaign").Create() tests := []struct { @@ -1063,9 +1063,9 @@ func TestRBAC(t *testing.T) { name: "owner can create access source", role: "owner", client: owner, - query: createAccessSourceMutation, + query: createAccessReviewSourceMutation, variables: func() map[string]any { - return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("AccessSource")}} + return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("AccessReviewSource")}} }, shouldAllow: true, }, @@ -1073,9 +1073,9 @@ func TestRBAC(t *testing.T) { name: "admin can create access source", role: "admin", client: admin, - query: createAccessSourceMutation, + query: createAccessReviewSourceMutation, variables: func() map[string]any { - return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("AccessSource")}} + return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("AccessReviewSource")}} }, shouldAllow: true, }, @@ -1083,9 +1083,9 @@ func TestRBAC(t *testing.T) { name: "viewer cannot create access source", role: "viewer", client: viewer, - query: createAccessSourceMutation, + query: createAccessReviewSourceMutation, variables: func() map[string]any { - return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("AccessSource")}} + return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("AccessReviewSource")}} }, shouldAllow: false, }, @@ -1094,9 +1094,9 @@ func TestRBAC(t *testing.T) { name: "owner can update access source", role: "owner", client: owner, - query: updateAccessSourceMutation, + query: updateAccessReviewSourceMutation, variables: func() map[string]any { - return map[string]any{"input": map[string]any{"accessSourceId": accessSourceID, "name": factory.SafeName("Updated Source")}} + return map[string]any{"input": map[string]any{"accessReviewSourceId": accessReviewSourceID, "name": factory.SafeName("Updated Source")}} }, shouldAllow: true, }, @@ -1104,9 +1104,9 @@ func TestRBAC(t *testing.T) { name: "admin can update access source", role: "admin", client: admin, - query: updateAccessSourceMutation, + query: updateAccessReviewSourceMutation, variables: func() map[string]any { - return map[string]any{"input": map[string]any{"accessSourceId": accessSourceID, "name": factory.SafeName("Updated Source")}} + return map[string]any{"input": map[string]any{"accessReviewSourceId": accessReviewSourceID, "name": factory.SafeName("Updated Source")}} }, shouldAllow: true, }, @@ -1114,9 +1114,9 @@ func TestRBAC(t *testing.T) { name: "viewer cannot update access source", role: "viewer", client: viewer, - query: updateAccessSourceMutation, + query: updateAccessReviewSourceMutation, variables: func() map[string]any { - return map[string]any{"input": map[string]any{"accessSourceId": accessSourceID, "name": factory.SafeName("Updated Source")}} + return map[string]any{"input": map[string]any{"accessReviewSourceId": accessReviewSourceID, "name": factory.SafeName("Updated Source")}} }, shouldAllow: false, }, @@ -1125,10 +1125,10 @@ func TestRBAC(t *testing.T) { name: "owner can delete access source", role: "owner", client: owner, - query: deleteAccessSourceMutation, + query: deleteAccessReviewSourceMutation, variables: func() map[string]any { - id := factory.NewAccessSource(owner, owner.GetOrganizationID().String()).WithName(factory.SafeName("ToDelete")).Create() - return map[string]any{"input": map[string]any{"accessSourceId": id}} + id := factory.NewAccessReviewSource(owner, owner.GetOrganizationID().String()).WithName(factory.SafeName("ToDelete")).Create() + return map[string]any{"input": map[string]any{"accessReviewSourceId": id}} }, shouldAllow: true, }, @@ -1136,10 +1136,10 @@ func TestRBAC(t *testing.T) { name: "admin can delete access source", role: "admin", client: admin, - query: deleteAccessSourceMutation, + query: deleteAccessReviewSourceMutation, variables: func() map[string]any { - id := factory.NewAccessSource(owner, owner.GetOrganizationID().String()).WithName(factory.SafeName("ToDelete")).Create() - return map[string]any{"input": map[string]any{"accessSourceId": id}} + id := factory.NewAccessReviewSource(owner, owner.GetOrganizationID().String()).WithName(factory.SafeName("ToDelete")).Create() + return map[string]any{"input": map[string]any{"accessReviewSourceId": id}} }, shouldAllow: true, }, @@ -1147,10 +1147,10 @@ func TestRBAC(t *testing.T) { name: "viewer cannot delete access source", role: "viewer", client: viewer, - query: deleteAccessSourceMutation, + query: deleteAccessReviewSourceMutation, variables: func() map[string]any { - id := factory.NewAccessSource(owner, owner.GetOrganizationID().String()).WithName(factory.SafeName("ToDelete")).Create() - return map[string]any{"input": map[string]any{"accessSourceId": id}} + id := factory.NewAccessReviewSource(owner, owner.GetOrganizationID().String()).WithName(factory.SafeName("ToDelete")).Create() + return map[string]any{"input": map[string]any{"accessReviewSourceId": id}} }, shouldAllow: false, }, @@ -1159,7 +1159,7 @@ func TestRBAC(t *testing.T) { name: "owner can list access sources", role: "owner", client: owner, - query: listAccessSourcesQuery, + query: listAccessReviewSourcesQuery, variables: func() map[string]any { return map[string]any{"id": owner.GetOrganizationID().String()} }, @@ -1169,7 +1169,7 @@ func TestRBAC(t *testing.T) { name: "admin can list access sources", role: "admin", client: admin, - query: listAccessSourcesQuery, + query: listAccessReviewSourcesQuery, variables: func() map[string]any { return map[string]any{"id": owner.GetOrganizationID().String()} }, @@ -1179,7 +1179,7 @@ func TestRBAC(t *testing.T) { name: "viewer can list access sources", role: "viewer", client: viewer, - query: listAccessSourcesQuery, + query: listAccessReviewSourcesQuery, variables: func() map[string]any { return map[string]any{"id": owner.GetOrganizationID().String()} }, diff --git a/e2e/internal/factory/factory.go b/e2e/internal/factory/factory.go index aacd6d276..fbabefc98 100644 --- a/e2e/internal/factory/factory.go +++ b/e2e/internal/factory/factory.go @@ -972,7 +972,7 @@ func (b *ProcessingActivityBuilder) Create() string { return CreateProcessingActivity(b.client, b.attrs) } -func CreateAccessSource(c *testutil.Client, organizationID string, attrs ...Attrs) string { +func CreateAccessReviewSource(c *testutil.Client, organizationID string, attrs ...Attrs) string { c.T.Helper() var a Attrs @@ -981,9 +981,9 @@ func CreateAccessSource(c *testutil.Client, organizationID string, attrs ...Attr } const query = ` - mutation($input: CreateAccessSourceInput!) { - createAccessSource(input: $input) { - accessSourceEdge { + mutation($input: CreateAccessReviewSourceInput!) { + createAccessReviewSource(input: $input) { + accessReviewSourceEdge { node { id } } } @@ -992,7 +992,7 @@ func CreateAccessSource(c *testutil.Client, organizationID string, attrs ...Attr input := map[string]any{ "organizationId": organizationID, - "name": a.getString("name", SafeName("AccessSource")), + "name": a.getString("name", SafeName("AccessReviewSource")), } if csvData := a.getStringPtr("csvData"); csvData != nil { input["csvData"] = *csvData @@ -1003,43 +1003,43 @@ func CreateAccessSource(c *testutil.Client, organizationID string, attrs ...Attr } var result struct { - CreateAccessSource struct { - AccessSourceEdge struct { + CreateAccessReviewSource struct { + AccessReviewSourceEdge struct { Node struct { ID string `json:"id"` } `json:"node"` - } `json:"accessSourceEdge"` - } `json:"createAccessSource"` + } `json:"accessReviewSourceEdge"` + } `json:"createAccessReviewSource"` } err := c.Execute(query, map[string]any{"input": input}, &result) - require.NoError(c.T, err, "createAccessSource mutation failed") + require.NoError(c.T, err, "createAccessReviewSource mutation failed") - return result.CreateAccessSource.AccessSourceEdge.Node.ID + return result.CreateAccessReviewSource.AccessReviewSourceEdge.Node.ID } -type AccessSourceBuilder struct { +type AccessReviewSourceBuilder struct { client *testutil.Client organizationID string attrs Attrs } -func NewAccessSource(c *testutil.Client, organizationID string) *AccessSourceBuilder { - return &AccessSourceBuilder{client: c, organizationID: organizationID, attrs: Attrs{}} +func NewAccessReviewSource(c *testutil.Client, organizationID string) *AccessReviewSourceBuilder { + return &AccessReviewSourceBuilder{client: c, organizationID: organizationID, attrs: Attrs{}} } -func (b *AccessSourceBuilder) WithName(name string) *AccessSourceBuilder { +func (b *AccessReviewSourceBuilder) WithName(name string) *AccessReviewSourceBuilder { b.attrs["name"] = name return b } -func (b *AccessSourceBuilder) WithCsvData(csvData string) *AccessSourceBuilder { +func (b *AccessReviewSourceBuilder) WithCsvData(csvData string) *AccessReviewSourceBuilder { b.attrs["csvData"] = csvData return b } -func (b *AccessSourceBuilder) Create() string { - return CreateAccessSource(b.client, b.organizationID, b.attrs) +func (b *AccessReviewSourceBuilder) Create() string { + return CreateAccessReviewSource(b.client, b.organizationID, b.attrs) } func CreateAccessReviewCampaign(c *testutil.Client, organizationID string, attrs ...Attrs) string { @@ -1065,8 +1065,8 @@ func CreateAccessReviewCampaign(c *testutil.Client, organizationID string, attrs "name": a.getString("name", SafeName("Campaign")), } - if v, ok := a["accessSourceIds"]; ok { - input["accessSourceIds"] = v + if v, ok := a["accessReviewSourceIds"]; ok { + input["accessReviewSourceIds"] = v } var result struct { @@ -1100,8 +1100,8 @@ func (b *AccessReviewCampaignBuilder) WithName(name string) *AccessReviewCampaig return b } -func (b *AccessReviewCampaignBuilder) WithAccessSourceIDs(ids []string) *AccessReviewCampaignBuilder { - b.attrs["accessSourceIds"] = ids +func (b *AccessReviewCampaignBuilder) WithAccessReviewSourceIDs(ids []string) *AccessReviewCampaignBuilder { + b.attrs["accessReviewSourceIds"] = ids return b } diff --git a/pkg/accessreview/actions.go b/pkg/accessreview/actions.go new file mode 100644 index 000000000..838a69e81 --- /dev/null +++ b/pkg/accessreview/actions.go @@ -0,0 +1,45 @@ +// Copyright (c) 2025-2026 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 accessreview + +// Access-review service actions. +// Format: access-review:: +const ( + // Campaign actions + ActionCampaignGet = "access-review:campaign:get" + ActionCampaignList = "access-review:campaign:list" + ActionCampaignCreate = "access-review:campaign:create" + ActionCampaignUpdate = "access-review:campaign:update" + ActionCampaignDelete = "access-review:campaign:delete" + ActionCampaignStart = "access-review:campaign:start" + ActionCampaignClose = "access-review:campaign:close" + ActionCampaignCancel = "access-review:campaign:cancel" + ActionCampaignAddSource = "access-review:campaign:add-source" + ActionCampaignRemoveSource = "access-review:campaign:remove-source" + + // Entry actions + ActionEntryGet = "access-review:entry:get" + ActionEntryList = "access-review:entry:list" + ActionEntryDecide = "access-review:entry:decide" + ActionEntryFlag = "access-review:entry:flag" + + // Source actions + ActionSourceGet = "access-review:source:get" + ActionSourceList = "access-review:source:list" + ActionSourceCreate = "access-review:source:create" + ActionSourceUpdate = "access-review:source:update" + ActionSourceDelete = "access-review:source:delete" + ActionSourceSync = "access-review:source:sync" +) diff --git a/pkg/accessreview/campaign_service.go b/pkg/accessreview/campaign_service.go index 707d1ea6f..679e9a918 100644 --- a/pkg/accessreview/campaign_service.go +++ b/pkg/accessreview/campaign_service.go @@ -25,20 +25,9 @@ import ( "go.probo.inc/probo/pkg/page" ) -type CampaignService struct { - pg *pg.Client - scope coredata.Scoper -} - -func NewCampaignService(pgClient *pg.Client, scope coredata.Scoper) *CampaignService { - return &CampaignService{ - pg: pgClient, - scope: scope, - } -} - -func (s *CampaignService) Create( +func (s *Service) CreateCampaign( ctx context.Context, + scope coredata.Scoper, req CreateAccessReviewCampaignRequest, ) (*coredata.AccessReviewCampaign, error) { if err := req.Validate(); err != nil { @@ -47,7 +36,7 @@ func (s *CampaignService) Create( now := time.Now() campaign := &coredata.AccessReviewCampaign{ - ID: gid.New(s.scope.GetTenantID(), coredata.AccessReviewCampaignEntityType), + ID: gid.New(scope.GetTenantID(), coredata.AccessReviewCampaignEntityType), OrganizationID: req.OrganizationID, Name: req.Name, Description: req.Description, @@ -60,13 +49,13 @@ func (s *CampaignService) Create( err := s.pg.WithTx( ctx, func(ctx context.Context, conn pg.Tx) error { - if err := campaign.Insert(ctx, conn, s.scope); err != nil { + if err := campaign.Insert(ctx, conn, scope); err != nil { return fmt.Errorf("cannot insert access review campaign: %w", err) } - for _, sourceID := range req.AccessSourceIDs { - source := &coredata.AccessSource{} - if err := source.LoadByID(ctx, conn, s.scope, sourceID); err != nil { + for _, sourceID := range req.AccessReviewSourceIDs { + source := &coredata.AccessReviewSource{} + if err := source.LoadByID(ctx, conn, scope, sourceID); err != nil { return fmt.Errorf("cannot load access source %s: %w", sourceID, err) } @@ -74,12 +63,8 @@ func (s *CampaignService) Create( return fmt.Errorf("cannot create campaign: access source %s does not belong to the same organization", sourceID) } - scopeSystem := coredata.AccessReviewCampaignScopeSystem{ - AccessReviewCampaignID: campaign.ID, - AccessSourceID: sourceID, - } - if err := scopeSystem.Insert(ctx, conn, s.scope); err != nil { - return fmt.Errorf("cannot insert scope system: %w", err) + if err := s.upsertCampaignSource(ctx, conn, scope, campaign.ID, source); err != nil { + return fmt.Errorf("cannot snapshot scope source: %w", err) } } @@ -93,8 +78,9 @@ func (s *CampaignService) Create( return campaign, nil } -func (s *CampaignService) Get( +func (s *Service) GetCampaign( ctx context.Context, + scope coredata.Scoper, campaignID gid.GID, ) (*coredata.AccessReviewCampaign, error) { campaign := &coredata.AccessReviewCampaign{} @@ -102,7 +88,7 @@ func (s *CampaignService) Get( err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - if err := campaign.LoadByID(ctx, conn, s.scope, campaignID); err != nil { + if err := campaign.LoadByID(ctx, conn, scope, campaignID); err != nil { return fmt.Errorf("cannot load campaign: %w", err) } @@ -116,8 +102,33 @@ func (s *CampaignService) Get( return campaign, nil } -func (s *CampaignService) Update( +func (s *Service) GetCampaignSource( ctx context.Context, + scope coredata.Scoper, + campaignSourceID gid.GID, +) (*coredata.AccessReviewCampaignSource, error) { + campaignSource := &coredata.AccessReviewCampaignSource{} + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := campaignSource.LoadByID(ctx, conn, scope, campaignSourceID); err != nil { + return fmt.Errorf("cannot load campaign source: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return campaignSource, nil +} + +func (s *Service) UpdateCampaign( + ctx context.Context, + scope coredata.Scoper, req UpdateAccessReviewCampaignRequest, ) (*coredata.AccessReviewCampaign, error) { if err := req.Validate(); err != nil { @@ -129,11 +140,11 @@ func (s *CampaignService) Update( err := s.pg.WithTx( ctx, func(ctx context.Context, conn pg.Tx) error { - if err := lockCampaignForUpdate(ctx, conn, s.scope, req.CampaignID); err != nil { + if err := lockCampaignForUpdate(ctx, conn, scope, req.CampaignID); err != nil { return fmt.Errorf("cannot lock campaign: %w", err) } - if err := campaign.LoadByID(ctx, conn, s.scope, req.CampaignID); err != nil { + if err := campaign.LoadByID(ctx, conn, scope, req.CampaignID); err != nil { return fmt.Errorf("cannot load campaign: %w", err) } @@ -155,7 +166,7 @@ func (s *CampaignService) Update( campaign.UpdatedAt = time.Now() - if err := campaign.Update(ctx, conn, s.scope); err != nil { + if err := campaign.Update(ctx, conn, scope); err != nil { return fmt.Errorf("cannot update campaign: %w", err) } @@ -169,19 +180,20 @@ func (s *CampaignService) Update( return campaign, nil } -func (s *CampaignService) Delete( +func (s *Service) DeleteCampaign( ctx context.Context, + scope coredata.Scoper, campaignID gid.GID, ) error { return s.pg.WithTx( ctx, func(ctx context.Context, conn pg.Tx) error { - if err := lockCampaignForUpdate(ctx, conn, s.scope, campaignID); err != nil { + if err := lockCampaignForUpdate(ctx, conn, scope, campaignID); err != nil { return fmt.Errorf("cannot lock campaign: %w", err) } campaign := &coredata.AccessReviewCampaign{} - if err := campaign.LoadByID(ctx, conn, s.scope, campaignID); err != nil { + if err := campaign.LoadByID(ctx, conn, scope, campaignID); err != nil { return fmt.Errorf("cannot load campaign: %w", err) } @@ -190,7 +202,7 @@ func (s *CampaignService) Delete( return fmt.Errorf("cannot delete campaign: status is %s, expected %s or %s", campaign.Status, coredata.AccessReviewCampaignStatusDraft, coredata.AccessReviewCampaignStatusCancelled) } - if err := campaign.Delete(ctx, conn, s.scope); err != nil { + if err := campaign.Delete(ctx, conn, scope); err != nil { return fmt.Errorf("cannot delete campaign: %w", err) } @@ -199,20 +211,21 @@ func (s *CampaignService) Delete( ) } -func (s *CampaignService) AddScopeSource( +func (s *Service) AddCampaignSource( ctx context.Context, - req AddCampaignScopeSourceRequest, + scope coredata.Scoper, + req AddCampaignSourceRequest, ) (*coredata.AccessReviewCampaign, error) { campaign := &coredata.AccessReviewCampaign{} err := s.pg.WithTx( ctx, func(ctx context.Context, conn pg.Tx) error { - if err := lockCampaignForUpdate(ctx, conn, s.scope, req.CampaignID); err != nil { + if err := lockCampaignForUpdate(ctx, conn, scope, req.CampaignID); err != nil { return fmt.Errorf("cannot lock campaign: %w", err) } - if err := campaign.LoadByID(ctx, conn, s.scope, req.CampaignID); err != nil { + if err := campaign.LoadByID(ctx, conn, scope, req.CampaignID); err != nil { return fmt.Errorf("cannot load campaign: %w", err) } @@ -220,21 +233,17 @@ func (s *CampaignService) AddScopeSource( return fmt.Errorf("cannot add scope source: campaign status is %s, expected %s", campaign.Status, coredata.AccessReviewCampaignStatusDraft) } - source := &coredata.AccessSource{} - if err := source.LoadByID(ctx, conn, s.scope, req.AccessSourceID); err != nil { - return fmt.Errorf("cannot load access source %s: %w", req.AccessSourceID, err) + source := &coredata.AccessReviewSource{} + if err := source.LoadByID(ctx, conn, scope, req.AccessReviewSourceID); err != nil { + return fmt.Errorf("cannot load access source %s: %w", req.AccessReviewSourceID, err) } if source.OrganizationID != campaign.OrganizationID { - return fmt.Errorf("cannot add scope source: access source %q does not belong to the same organization", req.AccessSourceID) + return fmt.Errorf("cannot add scope source: access source %q does not belong to the same organization", req.AccessReviewSourceID) } - scopeSystem := coredata.AccessReviewCampaignScopeSystem{ - AccessReviewCampaignID: campaign.ID, - AccessSourceID: req.AccessSourceID, - } - if err := scopeSystem.Upsert(ctx, conn, s.scope); err != nil { - return fmt.Errorf("cannot upsert scope system: %w", err) + if err := s.upsertCampaignSource(ctx, conn, scope, campaign.ID, source); err != nil { + return fmt.Errorf("cannot snapshot scope source: %w", err) } return nil @@ -247,20 +256,21 @@ func (s *CampaignService) AddScopeSource( return campaign, nil } -func (s *CampaignService) RemoveScopeSource( +func (s *Service) RemoveCampaignSource( ctx context.Context, - req RemoveCampaignScopeSourceRequest, + scope coredata.Scoper, + req RemoveCampaignSourceRequest, ) (*coredata.AccessReviewCampaign, error) { campaign := &coredata.AccessReviewCampaign{} err := s.pg.WithTx( ctx, func(ctx context.Context, conn pg.Tx) error { - if err := lockCampaignForUpdate(ctx, conn, s.scope, req.CampaignID); err != nil { + if err := lockCampaignForUpdate(ctx, conn, scope, req.CampaignID); err != nil { return fmt.Errorf("cannot lock campaign: %w", err) } - if err := campaign.LoadByID(ctx, conn, s.scope, req.CampaignID); err != nil { + if err := campaign.LoadByID(ctx, conn, scope, req.CampaignID); err != nil { return fmt.Errorf("cannot load campaign: %w", err) } @@ -268,12 +278,9 @@ func (s *CampaignService) RemoveScopeSource( return fmt.Errorf("cannot remove scope source: campaign status is %s, expected DRAFT", campaign.Status) } - scopeSystem := coredata.AccessReviewCampaignScopeSystem{ - AccessReviewCampaignID: campaign.ID, - AccessSourceID: req.AccessSourceID, - } - if err := scopeSystem.Delete(ctx, conn, s.scope); err != nil { - return fmt.Errorf("cannot delete scope system: %w", err) + campaignSource := &coredata.AccessReviewCampaignSource{} + if err := campaignSource.DeleteByCampaignIDAndAccessReviewSourceID(ctx, conn, scope, campaign.ID, req.AccessReviewSourceID); err != nil { + return fmt.Errorf("cannot delete campaign source: %w", err) } return nil @@ -286,8 +293,9 @@ func (s *CampaignService) RemoveScopeSource( return campaign, nil } -func (s *CampaignService) Start( +func (s *Service) StartCampaign( ctx context.Context, + scope coredata.Scoper, campaignID gid.GID, ) (*coredata.AccessReviewCampaign, error) { campaign := &coredata.AccessReviewCampaign{} @@ -295,11 +303,11 @@ func (s *CampaignService) Start( err := s.pg.WithTx( ctx, func(ctx context.Context, conn pg.Tx) error { - if err := lockCampaignForUpdate(ctx, conn, s.scope, campaignID); err != nil { + if err := lockCampaignForUpdate(ctx, conn, scope, campaignID); err != nil { return fmt.Errorf("cannot lock campaign: %w", err) } - if err := campaign.LoadByID(ctx, conn, s.scope, campaignID); err != nil { + if err := campaign.LoadByID(ctx, conn, scope, campaignID); err != nil { return fmt.Errorf("cannot load campaign: %w", err) } @@ -307,12 +315,12 @@ func (s *CampaignService) Start( return fmt.Errorf("cannot start campaign: status is %s, expected %s", campaign.Status, coredata.AccessReviewCampaignStatusDraft) } - var sources coredata.AccessSources - if err := sources.LoadScopeSourcesByCampaignID(ctx, conn, s.scope, campaign.ID); err != nil { - return fmt.Errorf("cannot load scope sources: %w", err) + var campaignSources coredata.AccessReviewCampaignSources + if err := campaignSources.LoadByCampaignID(ctx, conn, scope, campaign.ID); err != nil { + return fmt.Errorf("cannot load campaign sources: %w", err) } - if len(sources) == 0 { + if len(campaignSources) == 0 { return fmt.Errorf("cannot start campaign: no scope sources configured") } @@ -321,11 +329,11 @@ func (s *CampaignService) Start( campaign.StartedAt = &now campaign.UpdatedAt = now - if err := campaign.Update(ctx, conn, s.scope); err != nil { + if err := campaign.Update(ctx, conn, scope); err != nil { return fmt.Errorf("cannot update campaign: %w", err) } - if err := s.enqueueSourceFetches(ctx, conn, campaign.ID, sources); err != nil { + if err := s.enqueueSourceFetches(ctx, conn, scope, campaignSources); err != nil { return fmt.Errorf("cannot queue source fetches: %w", err) } @@ -339,8 +347,9 @@ func (s *CampaignService) Start( return campaign, nil } -func (s *CampaignService) Close( +func (s *Service) CloseCampaign( ctx context.Context, + scope coredata.Scoper, campaignID gid.GID, ) (*coredata.AccessReviewCampaign, error) { campaign := &coredata.AccessReviewCampaign{} @@ -348,11 +357,11 @@ func (s *CampaignService) Close( err := s.pg.WithTx( ctx, func(ctx context.Context, conn pg.Tx) error { - if err := lockCampaignForUpdate(ctx, conn, s.scope, campaignID); err != nil { + if err := lockCampaignForUpdate(ctx, conn, scope, campaignID); err != nil { return fmt.Errorf("cannot lock campaign: %w", err) } - if err := campaign.LoadByID(ctx, conn, s.scope, campaignID); err != nil { + if err := campaign.LoadByID(ctx, conn, scope, campaignID); err != nil { return fmt.Errorf("cannot load campaign: %w", err) } @@ -360,9 +369,9 @@ func (s *CampaignService) Close( return fmt.Errorf("cannot close campaign: status is %s, expected %s", campaign.Status, coredata.AccessReviewCampaignStatusPendingActions) } - entries := coredata.AccessEntries{} + entries := coredata.AccessReviewEntries{} - pendingCount, err := entries.CountPendingByCampaignID(ctx, conn, s.scope, campaignID) + pendingCount, err := entries.CountPendingByCampaignID(ctx, conn, scope, campaignID) if err != nil { return fmt.Errorf("cannot count pending entries: %w", err) } @@ -376,7 +385,7 @@ func (s *CampaignService) Close( campaign.CompletedAt = &now campaign.UpdatedAt = now - if err := campaign.Update(ctx, conn, s.scope); err != nil { + if err := campaign.Update(ctx, conn, scope); err != nil { return fmt.Errorf("cannot update campaign: %w", err) } @@ -399,29 +408,63 @@ func lockCampaignForUpdate(ctx context.Context, tx pg.Tx, scope coredata.Scoper, return nil } -func (s *CampaignService) enqueueSourceFetches( +// upsertCampaignSource snapshots a live access source into the campaign's scope +// so the review keeps the source identity even if the source is later deleted. +func (s *Service) upsertCampaignSource( ctx context.Context, tx pg.Tx, + scope coredata.Scoper, campaignID gid.GID, - sources coredata.AccessSources, + source *coredata.AccessReviewSource, +) error { + now := time.Now() + sourceID := source.ID + + campaignSource := &coredata.AccessReviewCampaignSource{ + ID: gid.New(scope.GetTenantID(), coredata.AccessReviewCampaignSourceEntityType), + AccessReviewCampaignID: campaignID, + AccessReviewSourceID: &sourceID, + Name: source.Name, + Category: source.Category, + ConnectorID: source.ConnectorID, + CreatedAt: now, + UpdatedAt: now, + } + + if err := campaignSource.Upsert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot upsert campaign source %s: %w", source.ID, err) + } + + return nil +} + +func (s *Service) enqueueSourceFetches( + ctx context.Context, + tx pg.Tx, + scope coredata.Scoper, + campaignSources coredata.AccessReviewCampaignSources, ) error { now := time.Now() - for _, source := range sources { - fetch := &coredata.AccessReviewCampaignSourceFetch{ - AccessReviewCampaignID: campaignID, - AccessSourceID: source.ID, + for _, campaignSource := range campaignSources { + attempt := &coredata.AccessReviewCampaignSourceFetchAttempt{ + ID: gid.New(scope.GetTenantID(), coredata.AccessReviewCampaignSourceFetchAttemptEntityType), + AccessReviewCampaignSourceID: campaignSource.ID, + Status: coredata.AccessReviewCampaignSourceFetchStatusQueued, + CreatedAt: now, + UpdatedAt: now, } - if err := fetch.UpsertQueued(ctx, tx, s.scope, now); err != nil { - return fmt.Errorf("cannot queue source fetch %s: %w", source.ID, err) + if err := attempt.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot queue source fetch %s: %w", campaignSource.ID, err) } } return nil } -func (s *CampaignService) Cancel( +func (s *Service) CancelCampaign( ctx context.Context, + scope coredata.Scoper, campaignID gid.GID, ) (*coredata.AccessReviewCampaign, error) { campaign := &coredata.AccessReviewCampaign{} @@ -429,11 +472,11 @@ func (s *CampaignService) Cancel( err := s.pg.WithTx( ctx, func(ctx context.Context, conn pg.Tx) error { - if err := lockCampaignForUpdate(ctx, conn, s.scope, campaignID); err != nil { + if err := lockCampaignForUpdate(ctx, conn, scope, campaignID); err != nil { return fmt.Errorf("cannot lock campaign: %w", err) } - if err := campaign.LoadByID(ctx, conn, s.scope, campaignID); err != nil { + if err := campaign.LoadByID(ctx, conn, scope, campaignID); err != nil { return fmt.Errorf("cannot load campaign: %w", err) } @@ -447,7 +490,7 @@ func (s *CampaignService) Cancel( campaign.CompletedAt = &now campaign.UpdatedAt = now - if err := campaign.Update(ctx, conn, s.scope); err != nil { + if err := campaign.Update(ctx, conn, scope); err != nil { return fmt.Errorf("cannot update campaign: %w", err) } @@ -461,8 +504,9 @@ func (s *CampaignService) Cancel( return campaign, nil } -func (s *CampaignService) ListForOrganizationID( +func (s *Service) ListCampaignsForOrganizationID( ctx context.Context, + scope coredata.Scoper, organizationID gid.GID, cursor *page.Cursor[coredata.AccessReviewCampaignOrderField], ) (*page.Page[*coredata.AccessReviewCampaign, coredata.AccessReviewCampaignOrderField], error) { @@ -471,7 +515,7 @@ func (s *CampaignService) ListForOrganizationID( err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - if err := campaigns.LoadByOrganizationID(ctx, conn, s.scope, organizationID, cursor); err != nil { + if err := campaigns.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor); err != nil { return fmt.Errorf("cannot load campaigns by organization: %w", err) } @@ -485,17 +529,18 @@ func (s *CampaignService) ListForOrganizationID( return page.NewPage(campaigns, cursor), nil } -func (s *CampaignService) ListSourceFetches( +func (s *Service) ListCampaignSources( ctx context.Context, + scope coredata.Scoper, campaignID gid.GID, -) (coredata.AccessReviewCampaignSourceFetches, error) { - var fetches coredata.AccessReviewCampaignSourceFetches +) (coredata.AccessReviewCampaignSources, error) { + var sources coredata.AccessReviewCampaignSources err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - if err := fetches.LoadByCampaignID(ctx, conn, s.scope, campaignID); err != nil { - return fmt.Errorf("cannot load source fetches by campaign: %w", err) + if err := sources.LoadByCampaignID(ctx, conn, scope, campaignID); err != nil { + return fmt.Errorf("cannot load campaign sources: %w", err) } return nil @@ -505,11 +550,60 @@ func (s *CampaignService) ListSourceFetches( return nil, err } - return fetches, nil + return sources, nil } -func (s *CampaignService) CountForOrganizationID( +func (s *Service) ListLatestFetchAttempts( ctx context.Context, + scope coredata.Scoper, + campaignID gid.GID, +) (coredata.AccessReviewCampaignSourceFetchAttempts, error) { + var attempts coredata.AccessReviewCampaignSourceFetchAttempts + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := attempts.LoadLatestByCampaignID(ctx, conn, scope, campaignID); err != nil { + return fmt.Errorf("cannot load latest fetch attempts by campaign: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return attempts, nil +} + +func (s *Service) ListFetchAttempts( + ctx context.Context, + scope coredata.Scoper, + campaignSourceID gid.GID, +) (coredata.AccessReviewCampaignSourceFetchAttempts, error) { + var attempts coredata.AccessReviewCampaignSourceFetchAttempts + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := attempts.LoadByCampaignSourceID(ctx, conn, scope, campaignSourceID); err != nil { + return fmt.Errorf("cannot load fetch attempts: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return attempts, nil +} + +func (s *Service) CountCampaignsForOrganizationID( + ctx context.Context, + scope coredata.Scoper, organizationID gid.GID, ) (int, error) { var count int @@ -519,7 +613,7 @@ func (s *CampaignService) CountForOrganizationID( func(ctx context.Context, conn pg.Querier) (err error) { campaigns := coredata.AccessReviewCampaigns{} - count, err = campaigns.CountByOrganizationID(ctx, conn, s.scope, organizationID) + count, err = campaigns.CountByOrganizationID(ctx, conn, scope, organizationID) if err != nil { return fmt.Errorf("cannot count campaigns by organization: %w", err) } diff --git a/pkg/accessreview/campaign_types.go b/pkg/accessreview/campaign_types.go index 75ac30acb..616446de9 100644 --- a/pkg/accessreview/campaign_types.go +++ b/pkg/accessreview/campaign_types.go @@ -24,11 +24,11 @@ const campaignNameMaxLength = 255 type ( CreateAccessReviewCampaignRequest struct { - OrganizationID gid.GID - Name string - Description string - FrameworkControls []string - AccessSourceIDs []gid.GID + OrganizationID gid.GID + Name string + Description string + FrameworkControls []string + AccessReviewSourceIDs []gid.GID } UpdateAccessReviewCampaignRequest struct { @@ -38,14 +38,14 @@ type ( FrameworkControls *[]string } - AddCampaignScopeSourceRequest struct { - CampaignID gid.GID - AccessSourceID gid.GID + AddCampaignSourceRequest struct { + CampaignID gid.GID + AccessReviewSourceID gid.GID } - RemoveCampaignScopeSourceRequest struct { - CampaignID gid.GID - AccessSourceID gid.GID + RemoveCampaignSourceRequest struct { + CampaignID gid.GID + AccessReviewSourceID gid.GID } ) diff --git a/pkg/accessreview/drivers/anthropic.go b/pkg/accessreview/drivers/anthropic.go index 289835fa2..0c837d858 100644 --- a/pkg/accessreview/drivers/anthropic.go +++ b/pkg/accessreview/drivers/anthropic.go @@ -75,8 +75,8 @@ func (d *AnthropicDriver) ListAccounts(ctx context.Context) ([]AccountRecord, er IsAdmin: u.Role == "admin", ExternalID: u.ID, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } // added_at is an RFC 3339 datetime string; ignore parse diff --git a/pkg/accessreview/drivers/asana.go b/pkg/accessreview/drivers/asana.go index da886db33..db356f920 100644 --- a/pkg/accessreview/drivers/asana.go +++ b/pkg/accessreview/drivers/asana.go @@ -103,8 +103,8 @@ func (d *AsanaDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) FullName: u.Name, ExternalID: u.GID, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, }, ) } diff --git a/pkg/accessreview/drivers/better_stack.go b/pkg/accessreview/drivers/better_stack.go index 6e998b101..6f9f18c94 100644 --- a/pkg/accessreview/drivers/better_stack.go +++ b/pkg/accessreview/drivers/better_stack.go @@ -98,8 +98,8 @@ func (d *BetterStackDriver) ListAccounts(ctx context.Context) ([]AccountRecord, Active: betterStackActive(member.Type), IsAdmin: betterStackIsAdmin(member.Attributes.Role), MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, ExternalID: member.ID, } diff --git a/pkg/accessreview/drivers/bitbucket.go b/pkg/accessreview/drivers/bitbucket.go index f3ce35b86..571a30674 100644 --- a/pkg/accessreview/drivers/bitbucket.go +++ b/pkg/accessreview/drivers/bitbucket.go @@ -100,8 +100,8 @@ func (d *BitbucketDriver) ListAccounts(ctx context.Context) ([]AccountRecord, er FullName: fullName, ExternalID: m.User.AccountID, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } records = append(records, record) diff --git a/pkg/accessreview/drivers/brex.go b/pkg/accessreview/drivers/brex.go index e6617a8b0..1a2690c8c 100644 --- a/pkg/accessreview/drivers/brex.go +++ b/pkg/accessreview/drivers/brex.go @@ -72,8 +72,8 @@ func (d *BrexDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) IsAdmin: false, ExternalID: u.ID, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } if record.Email != "" { diff --git a/pkg/accessreview/drivers/clerk.go b/pkg/accessreview/drivers/clerk.go index 73ba56af3..33caf700b 100644 --- a/pkg/accessreview/drivers/clerk.go +++ b/pkg/accessreview/drivers/clerk.go @@ -94,7 +94,7 @@ func (d *ClerkDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) IsAdmin: false, MFAStatus: clerkMFAStatus(u), AuthMethod: clerkAuthMethod(u), - AccountType: coredata.AccessEntryAccountTypeUser, + AccountType: coredata.AccessReviewEntryAccountTypeUser, ExternalID: u.ID, } @@ -211,12 +211,12 @@ func clerkMFAStatus(u clerkUser) coredata.MFAStatus { return coredata.MFAStatusDisabled } -func clerkAuthMethod(u clerkUser) coredata.AccessEntryAuthMethod { +func clerkAuthMethod(u clerkUser) coredata.AccessReviewEntryAuthMethod { if u.PasswordEnabled { - return coredata.AccessEntryAuthMethodPassword + return coredata.AccessReviewEntryAuthMethodPassword } - return coredata.AccessEntryAuthMethodUnknown + return coredata.AccessReviewEntryAuthMethodUnknown } func clerkUnixMillisToTime(unixMillis int64) *time.Time { diff --git a/pkg/accessreview/drivers/clerk_test.go b/pkg/accessreview/drivers/clerk_test.go index 7e8400a5b..9a4a6d00c 100644 --- a/pkg/accessreview/drivers/clerk_test.go +++ b/pkg/accessreview/drivers/clerk_test.go @@ -40,11 +40,11 @@ func TestClerkDriver(t *testing.T) { assert.Equal(t, "user_3EfkCEWmtIsoMD3rRxIpDsBOPzv", first.ExternalID) assert.Equal(t, "c@example.com", first.Email) assert.Equal(t, "c c", first.FullName) - assert.Equal(t, coredata.AccessEntryAccountTypeUser, first.AccountType) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, first.AccountType) require.NotNil(t, first.Active) assert.True(t, *first.Active) assert.Equal(t, coredata.MFAStatusDisabled, first.MFAStatus) - assert.Equal(t, coredata.AccessEntryAuthMethodPassword, first.AuthMethod) + assert.Equal(t, coredata.AccessReviewEntryAuthMethodPassword, first.AuthMethod) assert.NotNil(t, first.CreatedAt) assert.Nil(t, first.LastLogin) @@ -60,7 +60,7 @@ func TestClerkDriver(t *testing.T) { assert.Equal(t, "a a", third.FullName) require.NotNil(t, third.Active) assert.False(t, *third.Active) - assert.Equal(t, coredata.AccessEntryAuthMethodPassword, third.AuthMethod) + assert.Equal(t, coredata.AccessReviewEntryAuthMethodPassword, third.AuthMethod) } func TestClerkPrimaryEmail(t *testing.T) { diff --git a/pkg/accessreview/drivers/clickup.go b/pkg/accessreview/drivers/clickup.go index 7de848f1a..c822c1278 100644 --- a/pkg/accessreview/drivers/clickup.go +++ b/pkg/accessreview/drivers/clickup.go @@ -111,8 +111,8 @@ func (d *ClickUpDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro IsAdmin: isAdmin, ExternalID: m.User.ID.String(), MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } if m.InvitePending != nil { diff --git a/pkg/accessreview/drivers/cloudflare.go b/pkg/accessreview/drivers/cloudflare.go index e90022766..17f18526e 100644 --- a/pkg/accessreview/drivers/cloudflare.go +++ b/pkg/accessreview/drivers/cloudflare.go @@ -197,8 +197,8 @@ func (d *CloudflareDriver) queryAllMembers(ctx context.Context, accountID string IsAdmin: isAdmin, ExternalID: m.ID, MFAStatus: mfaStatus, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } if record.Email != "" { diff --git a/pkg/accessreview/drivers/csv.go b/pkg/accessreview/drivers/csv.go index 7dc7c3133..1d210842e 100644 --- a/pkg/accessreview/drivers/csv.go +++ b/pkg/accessreview/drivers/csv.go @@ -70,8 +70,8 @@ func (d *CSVDriver) ListAccounts(_ context.Context) ([]AccountRecord, error) { record := AccountRecord{ MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } if idx, ok := colIndex["email"]; ok && idx < len(row) { @@ -104,7 +104,7 @@ func (d *CSVDriver) ListAccounts(_ context.Context) ([]AccountRecord, error) { if idx, ok := colIndex["account_type"]; ok && idx < len(row) { if strings.TrimSpace(strings.ToUpper(row[idx])) == "SERVICE_ACCOUNT" { - record.AccountType = coredata.AccessEntryAccountTypeServiceAccount + record.AccountType = coredata.AccessReviewEntryAccountTypeServiceAccount } } diff --git a/pkg/accessreview/drivers/datadog.go b/pkg/accessreview/drivers/datadog.go index ce0ddf31e..8b1a4e2bf 100644 --- a/pkg/accessreview/drivers/datadog.go +++ b/pkg/accessreview/drivers/datadog.go @@ -123,9 +123,9 @@ func (d *DatadogDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro } } - accountType := coredata.AccessEntryAccountTypeUser + accountType := coredata.AccessReviewEntryAccountTypeUser if u.Attributes.ServiceAccount { - accountType = coredata.AccessEntryAccountTypeServiceAccount + accountType = coredata.AccessReviewEntryAccountTypeServiceAccount } mfaStatus := coredata.MFAStatusDisabled @@ -144,7 +144,7 @@ func (d *DatadogDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro // Datadog's /api/v2/users does not expose the login method // used (no allowed_login_methods in the schema), so the // auth method is unknown. - AuthMethod: coredata.AccessEntryAuthMethodUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AccountType: accountType, ExternalID: u.ID, CreatedAt: parseRFC3339Ptr(u.Attributes.CreatedAt), diff --git a/pkg/accessreview/drivers/datadog_test.go b/pkg/accessreview/drivers/datadog_test.go index fd700e06e..a03604a88 100644 --- a/pkg/accessreview/drivers/datadog_test.go +++ b/pkg/accessreview/drivers/datadog_test.go @@ -44,9 +44,9 @@ func TestDatadogDriver(t *testing.T) { assert.True(t, r.IsAdmin) assert.Equal(t, "Datadog Admin Role", r.Role) assert.Equal(t, "Security Engineer", r.JobTitle) - assert.Equal(t, coredata.AccessEntryAccountTypeUser, r.AccountType) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, r.AccountType) assert.Equal(t, coredata.MFAStatusEnabled, r.MFAStatus) - assert.Equal(t, coredata.AccessEntryAuthMethodUnknown, r.AuthMethod) + assert.Equal(t, coredata.AccessReviewEntryAuthMethodUnknown, r.AuthMethod) // Second record exercises the inactive, non-admin, and service-account // (MFA-disabled) branches. @@ -57,6 +57,6 @@ func TestDatadogDriver(t *testing.T) { assert.False(t, *r2.Active) assert.False(t, r2.IsAdmin) assert.Equal(t, "Datadog Standard Role", r2.Role) - assert.Equal(t, coredata.AccessEntryAccountTypeServiceAccount, r2.AccountType) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeServiceAccount, r2.AccountType) assert.Equal(t, coredata.MFAStatusDisabled, r2.MFAStatus) } diff --git a/pkg/accessreview/drivers/docusign.go b/pkg/accessreview/drivers/docusign.go index 901c37c5d..290cda806 100644 --- a/pkg/accessreview/drivers/docusign.go +++ b/pkg/accessreview/drivers/docusign.go @@ -99,8 +99,8 @@ func (d *DocuSignDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err IsAdmin: strings.EqualFold(u.IsAdmin, "True"), ExternalID: u.UserID, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } if u.LastLogin != "" { diff --git a/pkg/accessreview/drivers/driver.go b/pkg/accessreview/drivers/driver.go index 5cf86bc00..50107f7fe 100644 --- a/pkg/accessreview/drivers/driver.go +++ b/pkg/accessreview/drivers/driver.go @@ -42,8 +42,8 @@ type AccountRecord struct { Active *bool IsAdmin bool MFAStatus coredata.MFAStatus - AuthMethod coredata.AccessEntryAuthMethod - AccountType coredata.AccessEntryAccountType + AuthMethod coredata.AccessReviewEntryAuthMethod + AccountType coredata.AccessReviewEntryAccountType LastLogin *time.Time CreatedAt *time.Time ExternalID string // system-specific user ID diff --git a/pkg/accessreview/drivers/github.go b/pkg/accessreview/drivers/github.go index 126907866..2a1dd7749 100644 --- a/pkg/accessreview/drivers/github.go +++ b/pkg/accessreview/drivers/github.go @@ -108,9 +108,9 @@ func (d *GitHubDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error fullName = m.Login } - accountType := coredata.AccessEntryAccountTypeUser + accountType := coredata.AccessReviewEntryAccountTypeUser if m.Type == "Bot" { - accountType = coredata.AccessEntryAccountTypeServiceAccount + accountType = coredata.AccessReviewEntryAccountTypeServiceAccount } mfaStatus := coredata.MFAStatusUnknown @@ -130,7 +130,7 @@ func (d *GitHubDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error Active: new(membership.State == "active"), IsAdmin: membership.Role == "admin", MFAStatus: mfaStatus, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AccountType: accountType, ExternalID: strconv.FormatInt(m.ID, 10), } diff --git a/pkg/accessreview/drivers/gitlab.go b/pkg/accessreview/drivers/gitlab.go index 1273aaf31..3dbfe4271 100644 --- a/pkg/accessreview/drivers/gitlab.go +++ b/pkg/accessreview/drivers/gitlab.go @@ -105,8 +105,8 @@ func (d *GitLabDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error Active: &active, IsAdmin: m.AccessLevel >= 50, // 50 = Owner MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, ExternalID: strconv.FormatInt(m.ID, 10), } diff --git a/pkg/accessreview/drivers/google_workspace.go b/pkg/accessreview/drivers/google_workspace.go index 0f15aa3f9..3f79b0251 100644 --- a/pkg/accessreview/drivers/google_workspace.go +++ b/pkg/accessreview/drivers/google_workspace.go @@ -120,8 +120,8 @@ func (d *GoogleWorkspaceDriver) ListAccounts(ctx context.Context) ([]AccountReco IsAdmin: u.IsAdmin, ExternalID: u.Id, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodSSO, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodSSO, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } if u.IsEnrolledIn2Sv { diff --git a/pkg/accessreview/drivers/grafana.go b/pkg/accessreview/drivers/grafana.go index 1dbf60d51..713ea493d 100644 --- a/pkg/accessreview/drivers/grafana.go +++ b/pkg/accessreview/drivers/grafana.go @@ -81,8 +81,8 @@ func (d *GrafanaDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro Role: strings.TrimSpace(u.Role), IsAdmin: strings.EqualFold(strings.TrimSpace(u.Role), "Admin"), MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, ExternalID: strconv.Itoa(u.UserID), } diff --git a/pkg/accessreview/drivers/heroku.go b/pkg/accessreview/drivers/heroku.go index 23cfe8084..4c917985f 100644 --- a/pkg/accessreview/drivers/heroku.go +++ b/pkg/accessreview/drivers/heroku.go @@ -159,8 +159,8 @@ func (d *HerokuDriver) listTeamMembers(ctx context.Context) ([]AccountRecord, er Role: m.Role, IsAdmin: isAdmin, MFAStatus: mfaStatus, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, ExternalID: externalID, } @@ -268,8 +268,8 @@ func herokuPersonalRecord(externalID, email, role string, isAdmin bool) AccountR Role: role, IsAdmin: isAdmin, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, ExternalID: externalID, } } diff --git a/pkg/accessreview/drivers/hubspot.go b/pkg/accessreview/drivers/hubspot.go index 2f79f9049..a1a615f60 100644 --- a/pkg/accessreview/drivers/hubspot.go +++ b/pkg/accessreview/drivers/hubspot.go @@ -115,8 +115,8 @@ func (d *HubSpotDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro IsAdmin: u.SuperAdmin, ExternalID: u.ID, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } if record.Email != "" || record.ExternalID != "" { diff --git a/pkg/accessreview/drivers/intercom.go b/pkg/accessreview/drivers/intercom.go index 9819f4dd2..f814a253a 100644 --- a/pkg/accessreview/drivers/intercom.go +++ b/pkg/accessreview/drivers/intercom.go @@ -71,8 +71,8 @@ func (d *IntercomDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err IsAdmin: false, // Intercom API does not expose admin role information ExternalID: a.ID, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } if record.Email != "" || record.FullName != "" { diff --git a/pkg/accessreview/drivers/linear.go b/pkg/accessreview/drivers/linear.go index b383874ae..d582cb757 100644 --- a/pkg/accessreview/drivers/linear.go +++ b/pkg/accessreview/drivers/linear.go @@ -88,9 +88,9 @@ func (d *LinearDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error } for _, u := range resp.Data.Users.Nodes { - accountType := coredata.AccessEntryAccountTypeUser + accountType := coredata.AccessReviewEntryAccountTypeUser if strings.HasSuffix(u.Email, ".linear.app") { - accountType = coredata.AccessEntryAccountTypeServiceAccount + accountType = coredata.AccessReviewEntryAccountTypeServiceAccount } record := AccountRecord{ @@ -101,7 +101,7 @@ func (d *LinearDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error IsAdmin: u.Admin, ExternalID: u.ID, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AccountType: accountType, } diff --git a/pkg/accessreview/drivers/metabase.go b/pkg/accessreview/drivers/metabase.go index 3ef4e387d..aa2833bb8 100644 --- a/pkg/accessreview/drivers/metabase.go +++ b/pkg/accessreview/drivers/metabase.go @@ -83,8 +83,8 @@ func (d *MetabaseDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err IsAdmin: u.IsSuperuser, ExternalID: strconv.Itoa(u.ID), MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } if t, ok := parseMetabaseTimestamp(u.LastLogin); ok { diff --git a/pkg/accessreview/drivers/microsoft_365.go b/pkg/accessreview/drivers/microsoft_365.go index 0d41814e5..452cabbf8 100644 --- a/pkg/accessreview/drivers/microsoft_365.go +++ b/pkg/accessreview/drivers/microsoft_365.go @@ -178,8 +178,8 @@ func (d *Microsoft365Driver) ListAccounts(ctx context.Context) ([]AccountRecord, Active: &active, IsAdmin: isAdmin, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodSSO, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodSSO, + AccountType: coredata.AccessReviewEntryAccountTypeUser, ExternalID: u.ID, } diff --git a/pkg/accessreview/drivers/monday.go b/pkg/accessreview/drivers/monday.go index 9276f5dcc..0e68a4f7a 100644 --- a/pkg/accessreview/drivers/monday.go +++ b/pkg/accessreview/drivers/monday.go @@ -100,8 +100,8 @@ func (d *MondayDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error Active: &active, IsAdmin: u.IsAdmin, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, ExternalID: u.ID, } diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go index cf6a7b204..2280e8f8a 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -353,7 +353,7 @@ func (r *qoveryNameResolver) ResolveInstanceName(ctx context.Context) (string, e } // renderNameResolver resolves the Render workspace (owner) name from -// GET /v1/owners/{ownerId}, used to title the AccessSource "Render ". +// GET /v1/owners/{ownerId}, used to title the AccessReviewSource "Render ". type renderNameResolver struct { httpClient *http.Client ownerID string @@ -651,7 +651,7 @@ func (r *anthropicNameResolver) ResolveInstanceName(ctx context.Context) (string } // sendGridNameResolver resolves the SendGrid account's company name from -// the user profile endpoint, used as the AccessSource instance label. +// the user profile endpoint, used as the AccessReviewSource instance label. type sendGridNameResolver struct { httpClient *http.Client } @@ -1062,7 +1062,7 @@ func (r *pagerdutyNameResolver) ResolveInstanceName(_ context.Context) (string, // datadogNameResolver returns the Datadog site/region label stored in // connector settings (e.g. "US3"), captured during the OAuth callback. No -// HTTP call is required; the AccessSource title becomes "Datadog ". +// HTTP call is required; the AccessReviewSource title becomes "Datadog ". // Org-name resolution is intentionally omitted to keep scopes to // user_access_read (the org name endpoint needs org_management). type datadogNameResolver struct { @@ -1132,7 +1132,7 @@ func (r *oktaNameResolver) ResolveInstanceName(ctx context.Context) (string, err // zendeskNameResolver returns the Zendesk subdomain stored in connector // settings (e.g. "acme" for acme.zendesk.com), captured at connect time. No -// HTTP call is required; the AccessSource title becomes "Zendesk ". +// HTTP call is required; the AccessReviewSource title becomes "Zendesk ". // Account-name resolution is intentionally omitted to keep the scope to // users:read (Zendesk exposes no human account name on that scope). type zendeskNameResolver struct { diff --git a/pkg/accessreview/drivers/neon.go b/pkg/accessreview/drivers/neon.go index 7939e0a9b..aba484ed1 100644 --- a/pkg/accessreview/drivers/neon.go +++ b/pkg/accessreview/drivers/neon.go @@ -95,8 +95,8 @@ func (d *NeonDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) Active: new(m.User.DeactivatedAt == ""), IsAdmin: neonIsAdmin(m.Member.Role), MFAStatus: neonMFAStatus(m.User.HasMFA), - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, ExternalID: neonExternalID(m), CreatedAt: parseRFC3339Ptr(m.Member.JoinedAt), }) diff --git a/pkg/accessreview/drivers/netlify.go b/pkg/accessreview/drivers/netlify.go index a1dbf5951..9987ef143 100644 --- a/pkg/accessreview/drivers/netlify.go +++ b/pkg/accessreview/drivers/netlify.go @@ -84,8 +84,8 @@ func (d *NetlifyDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro Role: m.Role, ExternalID: m.ID, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } records = append(records, record) } diff --git a/pkg/accessreview/drivers/notion.go b/pkg/accessreview/drivers/notion.go index 1f94c8f8f..aea31b5b4 100644 --- a/pkg/accessreview/drivers/notion.go +++ b/pkg/accessreview/drivers/notion.go @@ -67,9 +67,9 @@ func (d *NotionDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error } for _, u := range resp.Results { - accountType := coredata.AccessEntryAccountTypeUser + accountType := coredata.AccessReviewEntryAccountTypeUser if u.Type == "bot" { - accountType = coredata.AccessEntryAccountTypeServiceAccount + accountType = coredata.AccessReviewEntryAccountTypeServiceAccount } var email string @@ -84,7 +84,7 @@ func (d *NotionDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error IsAdmin: false, ExternalID: u.ID, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AccountType: accountType, } diff --git a/pkg/accessreview/drivers/okta.go b/pkg/accessreview/drivers/okta.go index 7826f9a0e..c32cd715c 100644 --- a/pkg/accessreview/drivers/okta.go +++ b/pkg/accessreview/drivers/okta.go @@ -100,8 +100,8 @@ func (d *OktaDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) JobTitle: u.Profile.Title, Active: oktaActive(u.Status), MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, ExternalID: u.ID, } diff --git a/pkg/accessreview/drivers/onepassword.go b/pkg/accessreview/drivers/onepassword.go index 191ebaec1..b59c2e4f5 100644 --- a/pkg/accessreview/drivers/onepassword.go +++ b/pkg/accessreview/drivers/onepassword.go @@ -97,8 +97,8 @@ func (d *OnePasswordDriver) ListAccounts(ctx context.Context) ([]AccountRecord, Active: new(u.Active), ExternalID: u.ID, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } if record.FullName == "" && u.Name.Formatted != "" { diff --git a/pkg/accessreview/drivers/onepassword_users_api.go b/pkg/accessreview/drivers/onepassword_users_api.go index fd70ec2cb..e9f7d57aa 100644 --- a/pkg/accessreview/drivers/onepassword_users_api.go +++ b/pkg/accessreview/drivers/onepassword_users_api.go @@ -89,8 +89,8 @@ func (d *OnePasswordUsersAPIDriver) ListAccounts(ctx context.Context) ([]Account Active: new(u.State == "ACTIVE"), ExternalID: u.ID, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } if u.CreateTime != "" { diff --git a/pkg/accessreview/drivers/openai.go b/pkg/accessreview/drivers/openai.go index c854644fd..bcb83bc69 100644 --- a/pkg/accessreview/drivers/openai.go +++ b/pkg/accessreview/drivers/openai.go @@ -72,8 +72,8 @@ func (d *OpenAIDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error IsAdmin: u.Role == "owner", ExternalID: u.ID, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } if u.AddedAt != 0 { diff --git a/pkg/accessreview/drivers/pagerduty.go b/pkg/accessreview/drivers/pagerduty.go index a550671eb..fcbb2a4d7 100644 --- a/pkg/accessreview/drivers/pagerduty.go +++ b/pkg/accessreview/drivers/pagerduty.go @@ -83,8 +83,8 @@ func (d *PagerDutyDriver) ListAccounts(ctx context.Context) ([]AccountRecord, er Role: u.Role, IsAdmin: isAdmin, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, ExternalID: u.ID, } diff --git a/pkg/accessreview/drivers/posthog.go b/pkg/accessreview/drivers/posthog.go index 39e8bc47d..8a0d5eda4 100644 --- a/pkg/accessreview/drivers/posthog.go +++ b/pkg/accessreview/drivers/posthog.go @@ -296,8 +296,8 @@ func posthogAccountRecord(member posthogMember) AccountRecord { IsAdmin: posthogIsAdmin(member.Level), ExternalID: member.User.UUID, MFAStatus: posthogMFAStatus(member.Is2FAEnabled), - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } if record.ExternalID == "" { diff --git a/pkg/accessreview/drivers/probo_memberships.go b/pkg/accessreview/drivers/probo_memberships.go index d99f857e5..e80f19cde 100644 --- a/pkg/accessreview/drivers/probo_memberships.go +++ b/pkg/accessreview/drivers/probo_memberships.go @@ -76,8 +76,8 @@ func (d *ProboMembershipsDriver) ListAccounts(ctx context.Context) ([]AccountRec ExternalID: account.ID.String(), CreatedAt: &createdAt, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, }, ) } diff --git a/pkg/accessreview/drivers/qovery.go b/pkg/accessreview/drivers/qovery.go index 4e746d7e1..6b1123918 100644 --- a/pkg/accessreview/drivers/qovery.go +++ b/pkg/accessreview/drivers/qovery.go @@ -104,8 +104,8 @@ func (d *QoveryDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error Role: qoveryRole(member.Role), IsAdmin: qoveryIsAdmin(member.Role), MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, ExternalID: member.ID, } diff --git a/pkg/accessreview/drivers/render.go b/pkg/accessreview/drivers/render.go index bb9fe0f29..fc879b75a 100644 --- a/pkg/accessreview/drivers/render.go +++ b/pkg/accessreview/drivers/render.go @@ -103,8 +103,8 @@ func (d *RenderDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error Active: renderActive(member.Status), IsAdmin: renderIsAdmin(member.Role), MFAStatus: renderMFAStatus(member.MFAEnabled), - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, ExternalID: member.UserID, }) } diff --git a/pkg/accessreview/drivers/render_test.go b/pkg/accessreview/drivers/render_test.go index 60a3b8ea7..e257dd6ac 100644 --- a/pkg/accessreview/drivers/render_test.go +++ b/pkg/accessreview/drivers/render_test.go @@ -54,8 +54,8 @@ func TestRenderDriverListAccounts(t *testing.T) { assert.Equal(t, "Admin", records[0].Role) assert.True(t, records[0].IsAdmin) assert.Equal(t, coredata.MFAStatusEnabled, records[0].MFAStatus) - assert.Equal(t, coredata.AccessEntryAccountTypeUser, records[0].AccountType) - assert.Equal(t, coredata.AccessEntryAuthMethodUnknown, records[0].AuthMethod) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, records[0].AccountType) + assert.Equal(t, coredata.AccessReviewEntryAuthMethodUnknown, records[0].AuthMethod) assert.Equal(t, "usr-000000000000000000a1", records[0].ExternalID) require.NotNil(t, records[0].Active) assert.True(t, *records[0].Active) diff --git a/pkg/accessreview/drivers/resend.go b/pkg/accessreview/drivers/resend.go index bd19a8ff9..5031d8b4d 100644 --- a/pkg/accessreview/drivers/resend.go +++ b/pkg/accessreview/drivers/resend.go @@ -61,8 +61,8 @@ func (d *ResendDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error IsAdmin: false, ExternalID: k.ID, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeServiceAccount, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeServiceAccount, } if k.CreatedAt != "" { diff --git a/pkg/accessreview/drivers/resend_test.go b/pkg/accessreview/drivers/resend_test.go index 21322c412..b95fbcf5b 100644 --- a/pkg/accessreview/drivers/resend_test.go +++ b/pkg/accessreview/drivers/resend_test.go @@ -38,5 +38,5 @@ func TestResendDriver(t *testing.T) { r := records[0] assert.NotEmpty(t, r.FullName) assert.NotEmpty(t, r.ExternalID) - assert.Equal(t, coredata.AccessEntryAccountTypeServiceAccount, r.AccountType) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeServiceAccount, r.AccountType) } diff --git a/pkg/accessreview/drivers/sendgrid.go b/pkg/accessreview/drivers/sendgrid.go index 9541d4b2b..62efdb3e1 100644 --- a/pkg/accessreview/drivers/sendgrid.go +++ b/pkg/accessreview/drivers/sendgrid.go @@ -110,7 +110,7 @@ func (d *SendGridDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err ExternalID: strings.TrimSpace(teammate.Username), MFAStatus: mfaStatus, AuthMethod: sendGridAuthMethod(teammate), - AccountType: coredata.AccessEntryAccountTypeUser, + AccountType: coredata.AccessReviewEntryAccountTypeUser, }) } @@ -232,12 +232,12 @@ func sendGridRole(userType string, isAdmin bool) string { // authenticated through SSO (native or partner) is SSO; otherwise they sign in // with SendGrid's own credentials. Both flags are always present on the // teammate payload, so this is a definitive signal. -func sendGridAuthMethod(t sendGridTeammate) coredata.AccessEntryAuthMethod { +func sendGridAuthMethod(t sendGridTeammate) coredata.AccessReviewEntryAuthMethod { if t.IsSSO || t.IsPartnerSSO { - return coredata.AccessEntryAuthMethodSSO + return coredata.AccessReviewEntryAuthMethodSSO } - return coredata.AccessEntryAuthMethodPassword + return coredata.AccessReviewEntryAuthMethodPassword } // sendGridMFAStatus derives a teammate's MFA status from the auto-set 2fa diff --git a/pkg/accessreview/drivers/sendgrid_test.go b/pkg/accessreview/drivers/sendgrid_test.go index 935880b9e..7b657525f 100644 --- a/pkg/accessreview/drivers/sendgrid_test.go +++ b/pkg/accessreview/drivers/sendgrid_test.go @@ -46,9 +46,9 @@ func TestSendGridDriver(t *testing.T) { assert.Equal(t, "Owner", owner.Role) assert.True(t, owner.IsAdmin) assert.Equal(t, "owner@example.com", owner.ExternalID) - assert.Equal(t, coredata.AccessEntryAccountTypeUser, owner.AccountType) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, owner.AccountType) // is_sso=false on the owner -> authenticates with SendGrid credentials. - assert.Equal(t, coredata.AccessEntryAuthMethodPassword, owner.AuthMethod) + assert.Equal(t, coredata.AccessReviewEntryAuthMethodPassword, owner.AuthMethod) // The owner is a full-access user whose scope catalog contains BOTH // 2fa_exempt and 2fa_required, so the MFA signal is ambiguous and the // driver reports Unknown rather than guessing from scope ordering. @@ -66,7 +66,7 @@ func TestSendGridDriver(t *testing.T) { assert.False(t, teammate.IsAdmin) // Non-unified teammate: username is a handle distinct from the email. assert.Equal(t, "taylor-teammate", teammate.ExternalID) - assert.Equal(t, coredata.AccessEntryAuthMethodSSO, teammate.AuthMethod) + assert.Equal(t, coredata.AccessReviewEntryAuthMethodSSO, teammate.AuthMethod) assert.Equal(t, coredata.MFAStatusEnabled, teammate.MFAStatus) } diff --git a/pkg/accessreview/drivers/sentry.go b/pkg/accessreview/drivers/sentry.go index 1aeb4fd8c..a0a654a66 100644 --- a/pkg/accessreview/drivers/sentry.go +++ b/pkg/accessreview/drivers/sentry.go @@ -143,7 +143,7 @@ func (d *SentryDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error ExternalID: m.ID, MFAStatus: mfaStatus, AuthMethod: authMethod, - AccountType: coredata.AccessEntryAccountTypeUser, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } if m.User != nil && m.User.LastLogin != "" { @@ -216,14 +216,14 @@ func sentryNextLink(header string) string { return "" } -func sentryAuthMethod(flags map[string]bool, user *sentryUser) coredata.AccessEntryAuthMethod { +func sentryAuthMethod(flags map[string]bool, user *sentryUser) coredata.AccessReviewEntryAuthMethod { if flags["sso:linked"] { - return coredata.AccessEntryAuthMethodSSO + return coredata.AccessReviewEntryAuthMethodSSO } if user != nil && user.HasPasswordAuth { - return coredata.AccessEntryAuthMethodPassword + return coredata.AccessReviewEntryAuthMethodPassword } - return coredata.AccessEntryAuthMethodUnknown + return coredata.AccessReviewEntryAuthMethodUnknown } diff --git a/pkg/accessreview/drivers/signoz.go b/pkg/accessreview/drivers/signoz.go index d43ae585a..04e3f0876 100644 --- a/pkg/accessreview/drivers/signoz.go +++ b/pkg/accessreview/drivers/signoz.go @@ -86,8 +86,8 @@ func (d *SigNozDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error Active: sigNozActiveStatus(u.Status), IsAdmin: u.IsRoot || strings.EqualFold(role, "Admin"), MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, ExternalID: strings.TrimSpace(u.ID), } diff --git a/pkg/accessreview/drivers/slack.go b/pkg/accessreview/drivers/slack.go index 095f94fc2..d7b38ae55 100644 --- a/pkg/accessreview/drivers/slack.go +++ b/pkg/accessreview/drivers/slack.go @@ -91,9 +91,9 @@ func (d *SlackDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) continue } - accountType := coredata.AccessEntryAccountTypeUser + accountType := coredata.AccessReviewEntryAccountTypeUser if m.IsBot || m.IsAppUser { - accountType = coredata.AccessEntryAccountTypeServiceAccount + accountType = coredata.AccessReviewEntryAccountTypeServiceAccount } record := AccountRecord{ @@ -105,7 +105,7 @@ func (d *SlackDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) IsAdmin: m.IsAdmin || m.IsOwner || m.IsPrimaryOwner, ExternalID: m.ID, MFAStatus: slackMFAStatus(m.Has2FA), - AuthMethod: coredata.AccessEntryAuthMethodUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AccountType: accountType, } diff --git a/pkg/accessreview/drivers/supabase.go b/pkg/accessreview/drivers/supabase.go index b661cf666..c98f79849 100644 --- a/pkg/accessreview/drivers/supabase.go +++ b/pkg/accessreview/drivers/supabase.go @@ -69,8 +69,8 @@ func (d *SupabaseDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err IsAdmin: isAdmin, ExternalID: m.UserID, MFAStatus: mfaStatus, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } records = append(records, record) diff --git a/pkg/accessreview/drivers/tailscale.go b/pkg/accessreview/drivers/tailscale.go index fe31082db..d984d0f68 100644 --- a/pkg/accessreview/drivers/tailscale.go +++ b/pkg/accessreview/drivers/tailscale.go @@ -86,8 +86,8 @@ func (d *TailscaleDriver) ListAccounts(ctx context.Context) ([]AccountRecord, er // Tailscale has no local credentials; it always delegates // authentication to an upstream identity provider, so every // account is SSO regardless of which IdP backs the tailnet. - AuthMethod: coredata.AccessEntryAuthMethodSSO, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodSSO, + AccountType: coredata.AccessReviewEntryAccountTypeUser, } if u.Created != "" { diff --git a/pkg/accessreview/drivers/tally.go b/pkg/accessreview/drivers/tally.go index 91ed14bd1..79e78dd59 100644 --- a/pkg/accessreview/drivers/tally.go +++ b/pkg/accessreview/drivers/tally.go @@ -120,8 +120,8 @@ func (d *TallyDriver) listUsers(ctx context.Context) ([]AccountRecord, error) { Active: new(!u.IsDeleted), ExternalID: u.ID, MFAStatus: mfaStatus, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, CreatedAt: new(u.CreatedAt), } @@ -176,8 +176,8 @@ func (d *TallyDriver) listInvites(ctx context.Context) ([]AccountRecord, error) Active: new(false), ExternalID: inv.ID, MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, Role: "Invited", } diff --git a/pkg/accessreview/drivers/vercel.go b/pkg/accessreview/drivers/vercel.go index 6ad1ccf95..ff6b441d2 100644 --- a/pkg/accessreview/drivers/vercel.go +++ b/pkg/accessreview/drivers/vercel.go @@ -100,8 +100,8 @@ func (d *VercelDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error Active: &confirmed, IsAdmin: m.Role == "OWNER" || m.Role == "owner", MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, ExternalID: m.UID, } diff --git a/pkg/accessreview/drivers/zendesk.go b/pkg/accessreview/drivers/zendesk.go index b745807f1..a01dac695 100644 --- a/pkg/accessreview/drivers/zendesk.go +++ b/pkg/accessreview/drivers/zendesk.go @@ -138,8 +138,8 @@ func zendeskRecord(u zendeskUser) AccountRecord { MFAStatus: mfaStatus, // Zendesk's users API does not expose the sign-in method // (password / SSO / social), so the auth method is unknown. - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, ExternalID: strconv.FormatInt(u.ID, 10), LastLogin: parseRFC3339Ptr(lastLogin), CreatedAt: parseRFC3339Ptr(u.CreatedAt), diff --git a/pkg/accessreview/drivers/zendesk_test.go b/pkg/accessreview/drivers/zendesk_test.go index 84893625a..a9f3c887c 100644 --- a/pkg/accessreview/drivers/zendesk_test.go +++ b/pkg/accessreview/drivers/zendesk_test.go @@ -45,9 +45,9 @@ func TestZendeskDriver(t *testing.T) { assert.True(t, *r.Active) assert.True(t, r.IsAdmin) assert.Equal(t, "admin", r.Role) - assert.Equal(t, coredata.AccessEntryAccountTypeUser, r.AccountType) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, r.AccountType) assert.Equal(t, coredata.MFAStatusEnabled, r.MFAStatus) - assert.Equal(t, coredata.AccessEntryAuthMethodUnknown, r.AuthMethod) + assert.Equal(t, coredata.AccessReviewEntryAuthMethodUnknown, r.AuthMethod) require.NotNil(t, r.LastLogin) require.NotNil(t, r.CreatedAt) diff --git a/pkg/accessreview/access_entry_service.go b/pkg/accessreview/entry_service.go similarity index 59% rename from pkg/accessreview/access_entry_service.go rename to pkg/accessreview/entry_service.go index 5cec03af7..285973bca 100644 --- a/pkg/accessreview/access_entry_service.go +++ b/pkg/accessreview/entry_service.go @@ -27,35 +27,31 @@ import ( ) type ( - AccessEntryService struct { - pg *pg.Client - scope coredata.Scoper - } - - RecordAccessEntryDecisionRequest struct { + RecordAccessReviewEntryDecisionRequest struct { EntryID gid.GID - Decision coredata.AccessEntryDecision + Decision coredata.AccessReviewEntryDecision DecisionNote *string DecidedByID *gid.GID } - FlagAccessEntryRequest struct { + FlagAccessReviewEntryRequest struct { EntryID gid.GID - Flags []coredata.AccessEntryFlag + Flags []coredata.AccessReviewEntryFlag FlagReasons []string } ) -func (s AccessEntryService) Get( +func (s *Service) GetEntry( ctx context.Context, + scope coredata.Scoper, entryID gid.GID, -) (*coredata.AccessEntry, error) { - entry := &coredata.AccessEntry{} +) (*coredata.AccessReviewEntry, error) { + entry := &coredata.AccessReviewEntry{} err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - return entry.LoadByID(ctx, conn, s.scope, entryID) + return entry.LoadByID(ctx, conn, scope, entryID) }, ) if err != nil { @@ -65,31 +61,32 @@ func (s AccessEntryService) Get( return entry, nil } -func (s AccessEntryService) RecordDecision( +func (s *Service) RecordDecision( ctx context.Context, - req RecordAccessEntryDecisionRequest, -) (*coredata.AccessEntry, error) { - if req.Decision == coredata.AccessEntryDecisionPending { + scope coredata.Scoper, + req RecordAccessReviewEntryDecisionRequest, +) (*coredata.AccessReviewEntry, error) { + if req.Decision == coredata.AccessReviewEntryDecisionPending { return nil, fmt.Errorf("cannot decide access entry: invalid decision %q", req.Decision) } - if req.Decision != coredata.AccessEntryDecisionApproved { + if req.Decision != coredata.AccessReviewEntryDecisionApproved { if req.DecisionNote == nil || strings.TrimSpace(*req.DecisionNote) == "" { return nil, fmt.Errorf("cannot decide access entry: note is required for non-approved decisions") } } - entry := &coredata.AccessEntry{} + entry := &coredata.AccessReviewEntry{} err := s.pg.WithTx( ctx, func(ctx context.Context, conn pg.Tx) error { - if err := entry.LoadByID(ctx, conn, s.scope, req.EntryID); err != nil { + if err := entry.LoadByID(ctx, conn, scope, req.EntryID); err != nil { return fmt.Errorf("cannot load access entry: %w", err) } campaign := &coredata.AccessReviewCampaign{} - if err := campaign.LoadByID(ctx, conn, s.scope, entry.AccessReviewCampaignID); err != nil { + if err := campaign.LoadByID(ctx, conn, scope, entry.AccessReviewCampaignID); err != nil { return fmt.Errorf("cannot load campaign: %w", err) } @@ -105,34 +102,34 @@ func (s AccessEntryService) RecordDecision( entry.UpdatedAt = now if entry.Flags == nil { - entry.Flags = []coredata.AccessEntryFlag{} + entry.Flags = []coredata.AccessReviewEntryFlag{} } if entry.FlagReasons == nil { entry.FlagReasons = []string{} } - if req.Decision == coredata.AccessEntryDecisionRevoke || req.Decision == coredata.AccessEntryDecisionEscalate { + if req.Decision == coredata.AccessReviewEntryDecisionRevoke || req.Decision == coredata.AccessReviewEntryDecisionEscalate { if len(entry.Flags) == 0 { - entry.Flags = []coredata.AccessEntryFlag{coredata.AccessEntryFlagExcessive} + entry.Flags = []coredata.AccessReviewEntryFlag{coredata.AccessReviewEntryFlagExcessive} } } - if err := entry.Update(ctx, conn, s.scope); err != nil { + if err := entry.Update(ctx, conn, scope); err != nil { return fmt.Errorf("cannot record access entry decision: %w", err) } - history := &coredata.AccessEntryDecisionHistory{ - ID: gid.New(s.scope.GetTenantID(), coredata.AccessEntryDecisionHistoryEntityType), - OrganizationID: entry.OrganizationID, - AccessEntry: entry.ID, - Decision: entry.Decision, - DecisionNote: entry.DecisionNote, - DecidedBy: entry.DecidedBy, - DecidedAt: *entry.DecidedAt, - CreatedAt: now, + history := &coredata.AccessReviewEntryDecisionHistory{ + ID: gid.New(scope.GetTenantID(), coredata.AccessReviewEntryDecisionHistoryEntityType), + OrganizationID: entry.OrganizationID, + AccessReviewEntry: entry.ID, + Decision: entry.Decision, + DecisionNote: entry.DecisionNote, + DecidedBy: entry.DecidedBy, + DecidedAt: *entry.DecidedAt, + CreatedAt: now, } - if err := history.Insert(ctx, conn, s.scope); err != nil { + if err := history.Insert(ctx, conn, scope); err != nil { return fmt.Errorf("cannot insert decision history: %w", err) } @@ -143,7 +140,7 @@ func (s AccessEntryService) RecordDecision( return nil, fmt.Errorf("cannot record access entry decision: %w", err) } - updatedEntry, err := s.Get(ctx, req.EntryID) + updatedEntry, err := s.GetEntry(ctx, scope, req.EntryID) if err != nil { return nil, fmt.Errorf("cannot reload access entry after decision: %w", err) } @@ -151,16 +148,17 @@ func (s AccessEntryService) RecordDecision( return updatedEntry, nil } -func (s AccessEntryService) RecordDecisions( +func (s *Service) RecordDecisions( ctx context.Context, - decisions []RecordAccessEntryDecisionRequest, -) ([]*coredata.AccessEntry, error) { + scope coredata.Scoper, + decisions []RecordAccessReviewEntryDecisionRequest, +) ([]*coredata.AccessReviewEntry, error) { for _, d := range decisions { - if d.Decision == coredata.AccessEntryDecisionPending { + if d.Decision == coredata.AccessReviewEntryDecisionPending { return nil, fmt.Errorf("cannot bulk decide access entries: invalid decision %q", d.Decision) } - if d.Decision != coredata.AccessEntryDecisionApproved { + if d.Decision != coredata.AccessReviewEntryDecisionApproved { if d.DecisionNote == nil || strings.TrimSpace(*d.DecisionNote) == "" { return nil, fmt.Errorf( "cannot bulk decide access entries: note is required for non-approved decisions on entry %s", @@ -183,14 +181,14 @@ func (s AccessEntryService) RecordDecisions( verifiedCampaigns := make(map[gid.GID]bool) for _, d := range decisions { - entry := &coredata.AccessEntry{} - if err := entry.LoadByID(ctx, conn, s.scope, d.EntryID); err != nil { + entry := &coredata.AccessReviewEntry{} + if err := entry.LoadByID(ctx, conn, scope, d.EntryID); err != nil { return fmt.Errorf("cannot load access entry %s: %w", d.EntryID, err) } if !verifiedCampaigns[entry.AccessReviewCampaignID] { campaign := &coredata.AccessReviewCampaign{} - if err := campaign.LoadByID(ctx, conn, s.scope, entry.AccessReviewCampaignID); err != nil { + if err := campaign.LoadByID(ctx, conn, scope, entry.AccessReviewCampaignID); err != nil { return fmt.Errorf("cannot load campaign: %w", err) } @@ -209,34 +207,34 @@ func (s AccessEntryService) RecordDecisions( entry.UpdatedAt = now if entry.Flags == nil { - entry.Flags = []coredata.AccessEntryFlag{} + entry.Flags = []coredata.AccessReviewEntryFlag{} } if entry.FlagReasons == nil { entry.FlagReasons = []string{} } - if d.Decision == coredata.AccessEntryDecisionRevoke || d.Decision == coredata.AccessEntryDecisionEscalate { + if d.Decision == coredata.AccessReviewEntryDecisionRevoke || d.Decision == coredata.AccessReviewEntryDecisionEscalate { if len(entry.Flags) == 0 { - entry.Flags = []coredata.AccessEntryFlag{coredata.AccessEntryFlagExcessive} + entry.Flags = []coredata.AccessReviewEntryFlag{coredata.AccessReviewEntryFlagExcessive} } } - if err := entry.Update(ctx, conn, s.scope); err != nil { + if err := entry.Update(ctx, conn, scope); err != nil { return fmt.Errorf("cannot record decision for entry %s: %w", d.EntryID, err) } - history := &coredata.AccessEntryDecisionHistory{ - ID: gid.New(s.scope.GetTenantID(), coredata.AccessEntryDecisionHistoryEntityType), - OrganizationID: entry.OrganizationID, - AccessEntry: entry.ID, - Decision: entry.Decision, - DecisionNote: entry.DecisionNote, - DecidedBy: entry.DecidedBy, - DecidedAt: *entry.DecidedAt, - CreatedAt: now, + history := &coredata.AccessReviewEntryDecisionHistory{ + ID: gid.New(scope.GetTenantID(), coredata.AccessReviewEntryDecisionHistoryEntityType), + OrganizationID: entry.OrganizationID, + AccessReviewEntry: entry.ID, + Decision: entry.Decision, + DecisionNote: entry.DecisionNote, + DecidedBy: entry.DecidedBy, + DecidedAt: *entry.DecidedAt, + CreatedAt: now, } - if err := history.Insert(ctx, conn, s.scope); err != nil { + if err := history.Insert(ctx, conn, scope); err != nil { return fmt.Errorf("cannot insert decision history for entry %s: %w", d.EntryID, err) } } @@ -248,9 +246,9 @@ func (s AccessEntryService) RecordDecisions( return nil, fmt.Errorf("cannot record access entry decisions: %w", err) } - entries := make([]*coredata.AccessEntry, len(entryIDs)) + entries := make([]*coredata.AccessReviewEntry, len(entryIDs)) for i, id := range entryIDs { - entry, err := s.Get(ctx, id) + entry, err := s.GetEntry(ctx, scope, id) if err != nil { return nil, fmt.Errorf("cannot reload access entry %s: %w", id, err) } @@ -261,21 +259,22 @@ func (s AccessEntryService) RecordDecisions( return entries, nil } -func (s AccessEntryService) FlagEntry( +func (s *Service) FlagEntry( ctx context.Context, - req FlagAccessEntryRequest, -) (*coredata.AccessEntry, error) { - entry := &coredata.AccessEntry{} + scope coredata.Scoper, + req FlagAccessReviewEntryRequest, +) (*coredata.AccessReviewEntry, error) { + entry := &coredata.AccessReviewEntry{} err := s.pg.WithTx( ctx, func(ctx context.Context, conn pg.Tx) error { - if err := entry.LoadByID(ctx, conn, s.scope, req.EntryID); err != nil { + if err := entry.LoadByID(ctx, conn, scope, req.EntryID); err != nil { return fmt.Errorf("cannot load access entry: %w", err) } campaign := &coredata.AccessReviewCampaign{} - if err := campaign.LoadByID(ctx, conn, s.scope, entry.AccessReviewCampaignID); err != nil { + if err := campaign.LoadByID(ctx, conn, scope, entry.AccessReviewCampaignID); err != nil { return fmt.Errorf("cannot load campaign: %w", err) } @@ -287,7 +286,7 @@ func (s AccessEntryService) FlagEntry( entry.Flags = req.Flags if entry.Flags == nil { - entry.Flags = []coredata.AccessEntryFlag{} + entry.Flags = []coredata.AccessReviewEntryFlag{} } entry.FlagReasons = req.FlagReasons @@ -297,28 +296,29 @@ func (s AccessEntryService) FlagEntry( entry.UpdatedAt = now - return entry.UpdateFlags(ctx, conn, s.scope) + return entry.UpdateFlags(ctx, conn, scope) }, ) if err != nil { return nil, fmt.Errorf("cannot flag access entry: %w", err) } - return s.Get(ctx, req.EntryID) + return s.GetEntry(ctx, scope, req.EntryID) } -func (s AccessEntryService) ListForCampaignID( +func (s *Service) ListEntriesForCampaignID( ctx context.Context, + scope coredata.Scoper, campaignID gid.GID, - cursor *page.Cursor[coredata.AccessEntryOrderField], - filter *coredata.AccessEntryFilter, -) (*page.Page[*coredata.AccessEntry, coredata.AccessEntryOrderField], error) { - var entries coredata.AccessEntries + cursor *page.Cursor[coredata.AccessReviewEntryOrderField], + filter *coredata.AccessReviewEntryFilter, +) (*page.Page[*coredata.AccessReviewEntry, coredata.AccessReviewEntryOrderField], error) { + var entries coredata.AccessReviewEntries err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - return entries.LoadByCampaignID(ctx, conn, s.scope, campaignID, cursor, filter) + return entries.LoadByCampaignID(ctx, conn, scope, campaignID, cursor, filter) }, ) if err != nil { @@ -328,19 +328,20 @@ func (s AccessEntryService) ListForCampaignID( return page.NewPage(entries, cursor), nil } -func (s AccessEntryService) ListForCampaignIDAndSourceID( +func (s *Service) ListEntriesForCampaignIDAndSourceID( ctx context.Context, + scope coredata.Scoper, campaignID gid.GID, sourceID gid.GID, - cursor *page.Cursor[coredata.AccessEntryOrderField], - filter *coredata.AccessEntryFilter, -) (*page.Page[*coredata.AccessEntry, coredata.AccessEntryOrderField], error) { - var entries coredata.AccessEntries + cursor *page.Cursor[coredata.AccessReviewEntryOrderField], + filter *coredata.AccessReviewEntryFilter, +) (*page.Page[*coredata.AccessReviewEntry, coredata.AccessReviewEntryOrderField], error) { + var entries coredata.AccessReviewEntries err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - return entries.LoadByCampaignIDAndSourceID(ctx, conn, s.scope, campaignID, sourceID, cursor, filter) + return entries.LoadByCampaignIDAndSourceID(ctx, conn, scope, campaignID, sourceID, cursor, filter) }, ) if err != nil { @@ -350,19 +351,20 @@ func (s AccessEntryService) ListForCampaignIDAndSourceID( return page.NewPage(entries, cursor), nil } -func (s AccessEntryService) CountForCampaignID( +func (s *Service) CountEntriesForCampaignID( ctx context.Context, + scope coredata.Scoper, campaignID gid.GID, - filter *coredata.AccessEntryFilter, + filter *coredata.AccessReviewEntryFilter, ) (int, error) { var count int err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) (err error) { - entries := coredata.AccessEntries{} + entries := coredata.AccessReviewEntries{} - count, err = entries.CountByCampaignID(ctx, conn, s.scope, campaignID, filter) + count, err = entries.CountByCampaignID(ctx, conn, scope, campaignID, filter) if err != nil { return fmt.Errorf("cannot count access entries by campaign: %w", err) } @@ -377,20 +379,21 @@ func (s AccessEntryService) CountForCampaignID( return count, nil } -func (s AccessEntryService) CountForCampaignIDAndSourceID( +func (s *Service) CountEntriesForCampaignIDAndSourceID( ctx context.Context, + scope coredata.Scoper, campaignID gid.GID, sourceID gid.GID, - filter *coredata.AccessEntryFilter, + filter *coredata.AccessReviewEntryFilter, ) (int, error) { var count int err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) (err error) { - entries := coredata.AccessEntries{} + entries := coredata.AccessReviewEntries{} - count, err = entries.CountByCampaignIDAndSourceID(ctx, conn, s.scope, campaignID, sourceID, filter) + count, err = entries.CountByCampaignIDAndSourceID(ctx, conn, scope, campaignID, sourceID, filter) if err != nil { return fmt.Errorf("cannot count access entries by campaign and source: %w", err) } @@ -405,8 +408,9 @@ func (s AccessEntryService) CountForCampaignIDAndSourceID( return count, nil } -func (s AccessEntryService) CountPendingForCampaignID( +func (s *Service) CountPendingEntriesForCampaignID( ctx context.Context, + scope coredata.Scoper, campaignID gid.GID, ) (int, error) { var count int @@ -414,9 +418,9 @@ func (s AccessEntryService) CountPendingForCampaignID( err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) (err error) { - entries := coredata.AccessEntries{} + entries := coredata.AccessReviewEntries{} - count, err = entries.CountPendingByCampaignID(ctx, conn, s.scope, campaignID) + count, err = entries.CountPendingByCampaignID(ctx, conn, scope, campaignID) if err != nil { return fmt.Errorf("cannot count pending access entries: %w", err) } @@ -431,16 +435,17 @@ func (s AccessEntryService) CountPendingForCampaignID( return count, nil } -func (s AccessEntryService) DecisionHistory( +func (s *Service) EntryDecisionHistory( ctx context.Context, + scope coredata.Scoper, entryID gid.GID, -) (coredata.AccessEntryDecisionHistories, error) { - var histories coredata.AccessEntryDecisionHistories +) (coredata.AccessReviewEntryDecisionHistories, error) { + var histories coredata.AccessReviewEntryDecisionHistories err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - return histories.LoadByEntryID(ctx, conn, s.scope, entryID) + return histories.LoadByEntryID(ctx, conn, scope, entryID) }, ) if err != nil { @@ -450,16 +455,17 @@ func (s AccessEntryService) DecisionHistory( return histories, nil } -func (s AccessEntryService) Statistics( +func (s *Service) CampaignStatistics( ctx context.Context, + scope coredata.Scoper, campaignID gid.GID, -) (*coredata.AccessEntryStatistics, error) { - stats := &coredata.AccessEntryStatistics{} +) (*coredata.AccessReviewStatistics, error) { + stats := &coredata.AccessReviewStatistics{} err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - return stats.LoadByCampaignID(ctx, conn, s.scope, campaignID) + return stats.LoadByCampaignID(ctx, conn, scope, campaignID) }, ) if err != nil { @@ -469,17 +475,18 @@ func (s AccessEntryService) Statistics( return stats, nil } -func (s AccessEntryService) StatisticsForSource( +func (s *Service) CampaignSourceStatistics( ctx context.Context, + scope coredata.Scoper, campaignID gid.GID, sourceID gid.GID, -) (*coredata.AccessEntryStatistics, error) { - stats := &coredata.AccessEntryStatistics{} +) (*coredata.AccessReviewStatistics, error) { + stats := &coredata.AccessReviewStatistics{} err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - return stats.LoadByCampaignIDAndSourceID(ctx, conn, s.scope, campaignID, sourceID) + return stats.LoadByCampaignIDAndSourceID(ctx, conn, scope, campaignID, sourceID) }, ) if err != nil { diff --git a/pkg/accessreview/policies.go b/pkg/accessreview/policies.go new file mode 100644 index 000000000..27fc8e9b0 --- /dev/null +++ b/pkg/accessreview/policies.go @@ -0,0 +1,60 @@ +// Copyright (c) 2025-2026 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 accessreview + +import ( + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/iam/policy" +) + +var organizationCondition = policy.Equals("principal.organization_id", "resource.organization_id") + +// FullAccessPolicy grants complete access-review access, including campaign, +// entry, and source management, to organization owners and admins. +var FullAccessPolicy = policy.NewPolicy( + "access-review:full-access", + "Access Review Full Access", + policy.Allow( + ActionCampaignGet, ActionCampaignList, ActionCampaignCreate, + ActionCampaignUpdate, ActionCampaignDelete, ActionCampaignStart, + ActionCampaignClose, ActionCampaignCancel, ActionCampaignAddSource, + ActionCampaignRemoveSource, + ActionEntryGet, ActionEntryList, ActionEntryDecide, ActionEntryFlag, + ActionSourceGet, ActionSourceList, ActionSourceCreate, + ActionSourceUpdate, ActionSourceDelete, ActionSourceSync, + ).WithSID("access-review-full-access").When(organizationCondition), +).WithDescription("Full access-review access including campaign, entry, and source management") + +// ReadAccessPolicy grants read-only access-review access to viewers. +var ReadAccessPolicy = policy.NewPolicy( + "access-review:read-access", + "Access Review Read Access", + policy.Allow( + ActionCampaignGet, ActionCampaignList, + ActionEntryGet, ActionEntryList, + ActionSourceGet, ActionSourceList, + ).WithSID("access-review-read-access").When(organizationCondition), +).WithDescription("Read-only access-review access") + +// PolicySet returns the PolicySet for the access-review service. It is owned by +// this package and registered into the authorizer at composition time so the +// access-review authorization rules live alongside the access-review domain +// logic instead of in the core probo policy set. +func PolicySet() *iam.PolicySet { + return iam.NewPolicySet(). + AddRolePolicy("OWNER", FullAccessPolicy). + AddRolePolicy("ADMIN", FullAccessPolicy). + AddRolePolicy("VIEWER", ReadAccessPolicy) +} diff --git a/pkg/accessreview/review_engine.go b/pkg/accessreview/review_engine.go index 8b1826cac..b40ae727f 100644 --- a/pkg/accessreview/review_engine.go +++ b/pkg/accessreview/review_engine.go @@ -22,66 +22,42 @@ import ( "strings" "time" - "go.gearno.de/kit/log" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/accessreview/drivers" "go.probo.inc/probo/pkg/connector" - "go.probo.inc/probo/pkg/connector/provider" "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/crypto/cipher" "go.probo.inc/probo/pkg/gid" ) -// ReviewEngine contains the stateless core logic for access review campaigns: -// snapshot and source data collection. -type ReviewEngine struct { - pg *pg.Client - scope coredata.Scoper - encryptionKey cipher.EncryptionKey - connectorRegistry *connector.ConnectorRegistry - providerRegistry *provider.Registry - logger *log.Logger -} - -func NewReviewEngine( - pgClient *pg.Client, - scope coredata.Scoper, - encryptionKey cipher.EncryptionKey, - connectorRegistry *connector.ConnectorRegistry, - providerRegistry *provider.Registry, - logger *log.Logger, -) *ReviewEngine { - return &ReviewEngine{ - pg: pgClient, - scope: scope, - encryptionKey: encryptionKey, - connectorRegistry: connectorRegistry, - providerRegistry: providerRegistry, - logger: logger, - } -} - -// FetchSource pulls accounts from a single source and upserts access entries. -func (e *ReviewEngine) FetchSource( +// FetchSource pulls accounts from a single campaign source snapshot and upserts +// access entries against that snapshot. +func (s *Service) FetchSource( ctx context.Context, + scope coredata.Scoper, campaign *coredata.AccessReviewCampaign, - sourceID gid.GID, + campaignSource *coredata.AccessReviewCampaignSource, ) (int, error) { fetchedCount := 0 + if campaignSource.AccessReviewSourceID == nil { + return 0, fmt.Errorf("cannot fetch source %s: the access source no longer exists", campaignSource.ID) + } + + sourceID := *campaignSource.AccessReviewSourceID + // Resolve the driver and load baseline data outside the write transaction // so that external HTTP calls do not hold a database connection. var ( - source *coredata.AccessSource + source *coredata.AccessReviewSource driver drivers.Driver baseline []coredata.BaselineAccountEntry ) - err := e.pg.WithTx( + err := s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { - source = &coredata.AccessSource{} - if err := source.LoadByID(ctx, tx, e.scope, sourceID); err != nil { + source = &coredata.AccessReviewSource{} + if err := source.LoadByID(ctx, tx, scope, sourceID); err != nil { return fmt.Errorf("cannot load access source %s: %w", sourceID, err) } @@ -91,20 +67,20 @@ func (e *ReviewEngine) FetchSource( var err error - driver, err = e.resolveDriver(ctx, tx, source) + driver, err = s.resolveDriver(ctx, tx, scope, source) if err != nil { return fmt.Errorf("cannot resolve driver for source %s: %w", source.Name, err) } lastCompletedCampaign := &coredata.AccessReviewCampaign{} - if err := lastCompletedCampaign.LoadLastCompletedByOrganizationID(ctx, tx, e.scope, campaign.OrganizationID); err != nil { + if err := lastCompletedCampaign.LoadLastCompletedByOrganizationID(ctx, tx, scope, campaign.OrganizationID); err != nil { if !errors.Is(err, coredata.ErrResourceNotFound) { return fmt.Errorf("cannot load last completed campaign: %w", err) } } else { - entries := &coredata.AccessEntries{} + entries := &coredata.AccessReviewEntries{} - baseline, err = entries.LoadBaselineBySourceID(ctx, tx, e.scope, lastCompletedCampaign.ID, sourceID) + baseline, err = entries.LoadBaselineBySourceID(ctx, tx, scope, lastCompletedCampaign.ID, sourceID) if err != nil { return fmt.Errorf("cannot load baseline entries by source: %w", err) } @@ -133,7 +109,7 @@ func (e *ReviewEngine) FetchSource( fetchedCount = len(accounts) - err = e.pg.WithTx( + err = s.pg.WithTx( ctx, func(ctx context.Context, conn pg.Tx) error { now := time.Now() @@ -143,38 +119,38 @@ func (e *ReviewEngine) FetchSource( accountKey := normalizeAccountKey(account.Email, account.ExternalID) seenAccountKeys[accountKey] = struct{}{} - incrementalTag := coredata.AccessEntryIncrementalTagNew + incrementalTag := coredata.AccessReviewEntryIncrementalTagNew if _, ok := previousByAccountKey[accountKey]; ok { - incrementalTag = coredata.AccessEntryIncrementalTagUnchanged + incrementalTag = coredata.AccessReviewEntryIncrementalTagUnchanged } - entry := &coredata.AccessEntry{ - ID: gid.New(e.scope.GetTenantID(), coredata.AccessEntryEntityType), - OrganizationID: campaign.OrganizationID, - AccessReviewCampaignID: campaign.ID, - AccessSourceID: sourceID, - Email: account.Email, - FullName: account.FullName, - Role: account.Role, - JobTitle: account.JobTitle, - IsAdmin: account.IsAdmin, - MFAStatus: account.MFAStatus, - AuthMethod: account.AuthMethod, - AccountType: account.AccountType, - Active: account.Active, - LastLogin: account.LastLogin, - AccountCreatedAt: account.CreatedAt, - ExternalID: account.ExternalID, - AccountKey: accountKey, - IncrementalTag: incrementalTag, - Flags: []coredata.AccessEntryFlag{}, - FlagReasons: []string{}, - Decision: coredata.AccessEntryDecisionPending, - CreatedAt: now, - UpdatedAt: now, + entry := &coredata.AccessReviewEntry{ + ID: gid.New(scope.GetTenantID(), coredata.AccessReviewEntryEntityType), + OrganizationID: campaign.OrganizationID, + AccessReviewCampaignID: campaign.ID, + AccessReviewCampaignSourceID: campaignSource.ID, + Email: account.Email, + FullName: account.FullName, + Role: account.Role, + JobTitle: account.JobTitle, + IsAdmin: account.IsAdmin, + MFAStatus: account.MFAStatus, + AuthMethod: account.AuthMethod, + AccountType: account.AccountType, + Active: account.Active, + LastLogin: account.LastLogin, + AccountCreatedAt: account.CreatedAt, + ExternalID: account.ExternalID, + AccountKey: accountKey, + IncrementalTag: incrementalTag, + Flags: []coredata.AccessReviewEntryFlag{}, + FlagReasons: []string{}, + Decision: coredata.AccessReviewEntryDecisionPending, + CreatedAt: now, + UpdatedAt: now, } - if err := entry.Upsert(ctx, conn, e.scope); err != nil { + if err := entry.Upsert(ctx, conn, scope); err != nil { return fmt.Errorf("cannot upsert access entry: %w", err) } } @@ -186,26 +162,26 @@ func (e *ReviewEngine) FetchSource( continue } - entry := &coredata.AccessEntry{ - ID: gid.New(e.scope.GetTenantID(), coredata.AccessEntryEntityType), - OrganizationID: campaign.OrganizationID, - AccessReviewCampaignID: campaign.ID, - AccessSourceID: sourceID, - Email: prev.Email, - FullName: prev.FullName, - AccountKey: accountKey, - IncrementalTag: coredata.AccessEntryIncrementalTagRemoved, - Flags: []coredata.AccessEntryFlag{}, - FlagReasons: []string{}, - Decision: coredata.AccessEntryDecisionPending, - MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, - CreatedAt: now, - UpdatedAt: now, + entry := &coredata.AccessReviewEntry{ + ID: gid.New(scope.GetTenantID(), coredata.AccessReviewEntryEntityType), + OrganizationID: campaign.OrganizationID, + AccessReviewCampaignID: campaign.ID, + AccessReviewCampaignSourceID: campaignSource.ID, + Email: prev.Email, + FullName: prev.FullName, + AccountKey: accountKey, + IncrementalTag: coredata.AccessReviewEntryIncrementalTagRemoved, + Flags: []coredata.AccessReviewEntryFlag{}, + FlagReasons: []string{}, + Decision: coredata.AccessReviewEntryDecisionPending, + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, + CreatedAt: now, + UpdatedAt: now, } - if err := entry.Upsert(ctx, conn, e.scope); err != nil { + if err := entry.Upsert(ctx, conn, scope); err != nil { return fmt.Errorf("cannot upsert removed access entry: %w", err) } } @@ -233,13 +209,13 @@ func normalizeAccountKey(email, externalID string) string { // oauthClient returns an HTTP client for an OAuth2 connection, using // RefreshableClient when a refresh config is available for the provider. -func (e *ReviewEngine) oauthClient( +func (s *Service) oauthClient( ctx context.Context, conn *connector.OAuth2Connection, provider coredata.ConnectorProvider, ) (*http.Client, error) { - if e.connectorRegistry != nil { - refreshCfg := e.connectorRegistry.GetOAuth2RefreshConfig(string(provider)) + if s.connectorRegistry != nil { + refreshCfg := s.connectorRegistry.GetOAuth2RefreshConfig(string(provider)) if refreshCfg != nil { return conn.RefreshableClient(ctx, *refreshCfg) } @@ -252,23 +228,24 @@ func (e *ReviewEngine) oauthClient( // For OAuth2 connections it delegates to oauthClient so that token refresh // is handled transparently. For other connection types it falls back to // the standard Client method. -func (e *ReviewEngine) connectorHTTPClient( +func (s *Service) connectorHTTPClient( ctx context.Context, dbConnector *coredata.Connector, ) (*http.Client, error) { if oauth2Conn, ok := dbConnector.Connection.(*connector.OAuth2Connection); ok { - return e.oauthClient(ctx, oauth2Conn, dbConnector.Provider) + return s.oauthClient(ctx, oauth2Conn, dbConnector.Provider) } return dbConnector.Connection.Client(ctx) } -// resolveDriver creates a Driver for the given AccessSource based on +// resolveDriver creates a Driver for the given AccessReviewSource based on // connector_id (null = built-in, set = connector-backed). -func (e *ReviewEngine) resolveDriver( +func (s *Service) resolveDriver( ctx context.Context, tx pg.Tx, - source *coredata.AccessSource, + scope coredata.Scoper, + source *coredata.AccessReviewSource, ) (drivers.Driver, error) { if source.ConnectorID == nil { // CSV-backed source: use CSVDriver when csv_data is present @@ -277,12 +254,12 @@ func (e *ReviewEngine) resolveDriver( } // Built-in driver: default to ProboMemberships - return drivers.NewProboMembershipsDriver(e.pg, e.scope, source.OrganizationID), nil + return drivers.NewProboMembershipsDriver(s.pg, scope, source.OrganizationID), nil } // Connector-backed: look up the connector and resolve driver by provider dbConnector := &coredata.Connector{} - if err := dbConnector.LoadByID(ctx, tx, e.scope, *source.ConnectorID, e.encryptionKey); err != nil { + if err := dbConnector.LoadByID(ctx, tx, scope, *source.ConnectorID, s.encryptionKey); err != nil { return nil, fmt.Errorf("cannot load connector %s: %w", *source.ConnectorID, err) } @@ -294,7 +271,7 @@ func (e *ReviewEngine) resolveDriver( // Build an HTTP client. For OAuth2 connections, use RefreshableClient // so that short-lived tokens are transparently refreshed. - httpClient, err := e.connectorHTTPClient(ctx, dbConnector) + httpClient, err := s.connectorHTTPClient(ctx, dbConnector) if err != nil { return nil, fmt.Errorf("cannot create HTTP client for %s connector: %w", dbConnector.Provider, err) } @@ -306,16 +283,16 @@ func (e *ReviewEngine) resolveDriver( if oauth2Conn, ok := dbConnector.Connection.(*connector.OAuth2Connection); ok { if oauth2Conn.AccessToken != tokenBefore { dbConnector.UpdatedAt = time.Now() - if err := dbConnector.Update(ctx, tx, e.scope, e.encryptionKey); err != nil { + if err := dbConnector.Update(ctx, tx, scope, s.encryptionKey); err != nil { return nil, fmt.Errorf("cannot persist refreshed token for connector %s: %w", *source.ConnectorID, err) } } } - reg, ok := e.providerRegistry.Get(dbConnector.Provider) + reg, ok := s.providerRegistry.Get(dbConnector.Provider) if !ok || reg.NewDriver == nil { return nil, fmt.Errorf("cannot resolve driver: unsupported provider %q", dbConnector.Provider) } - return reg.NewDriver(ctx, httpClient, dbConnector, e.logger) + return reg.NewDriver(ctx, httpClient, dbConnector, s.logger) } diff --git a/pkg/accessreview/service.go b/pkg/accessreview/service.go index f4b3f3ecf..54852d6f0 100644 --- a/pkg/accessreview/service.go +++ b/pkg/accessreview/service.go @@ -38,8 +38,8 @@ type ( providerRegistry *provider.Registry logger *log.Logger - fetchWorker *worker.Worker[coredata.AccessReviewCampaignSourceFetch] - sourceNameWorker *worker.Worker[coredata.AccessSource] + fetchWorker *worker.Worker[coredata.AccessReviewCampaignSourceFetchAttempt] + sourceNameWorker *worker.Worker[coredata.AccessReviewSource] } Option func(*options) @@ -102,39 +102,6 @@ func NewService( return s } -// Sources returns a tenant-scoped AccessSourceService. -func (s *Service) Sources(scope coredata.Scoper) *AccessSourceService { - return &AccessSourceService{ - pg: s.pg, - scope: scope, - encryptionKey: s.encryptionKey, - connectorRegistry: s.connectorRegistry, - providerRegistry: s.providerRegistry, - } -} - -// Campaigns returns a tenant-scoped CampaignService. -func (s *Service) Campaigns(scope coredata.Scoper) *CampaignService { - return NewCampaignService(s.pg, scope) -} - -// Entries returns a tenant-scoped AccessEntryService. -func (s *Service) Entries(scope coredata.Scoper) *AccessEntryService { - return &AccessEntryService{pg: s.pg, scope: scope} -} - -// Engine returns a tenant-scoped ReviewEngine. -func (s *Service) Engine(scope coredata.Scoper) *ReviewEngine { - return NewReviewEngine( - s.pg, - scope, - s.encryptionKey, - s.connectorRegistry, - s.providerRegistry, - s.logger.Named("review_engine"), - ) -} - // ResolveEntryOrganizationID resolves the organization ID for an access entry. // This is unscoped because it is used by resolvers before authorization to // find the organization from an entry ID. @@ -146,7 +113,7 @@ func (s *Service) ResolveEntryOrganizationID(ctx context.Context, entryID gid.GI func(ctx context.Context, conn pg.Querier) error { var err error - entry := &coredata.AccessEntry{} + entry := &coredata.AccessReviewEntry{} organizationID, err = entry.LoadOrganizationID(ctx, conn, entryID) if err != nil { diff --git a/pkg/accessreview/source_name_worker.go b/pkg/accessreview/source_name_worker.go index 7c17ead77..4751fe2a0 100644 --- a/pkg/accessreview/source_name_worker.go +++ b/pkg/accessreview/source_name_worker.go @@ -48,7 +48,7 @@ func NewSourceNameWorker( providerRegistry *provider.Registry, logger *log.Logger, opts ...worker.Option, -) *worker.Worker[coredata.AccessSource] { +) *worker.Worker[coredata.AccessReviewSource] { h := &sourceNameHandler{ pg: pgClient, encryptionKey: encryptionKey, @@ -70,8 +70,8 @@ func NewSourceNameWorker( ) } -func (h *sourceNameHandler) Claim(ctx context.Context) (coredata.AccessSource, error) { - var source coredata.AccessSource +func (h *sourceNameHandler) Claim(ctx context.Context) (coredata.AccessReviewSource, error) { + var source coredata.AccessReviewSource err := h.pg.WithTx( ctx, @@ -80,17 +80,17 @@ func (h *sourceNameHandler) Claim(ctx context.Context) (coredata.AccessSource, e }, ) if err != nil { - if errors.Is(err, coredata.ErrNoAccessSourceNameSyncAvailable) { - return coredata.AccessSource{}, worker.ErrNoTask + if errors.Is(err, coredata.ErrNoAccessReviewSourceNameSyncAvailable) { + return coredata.AccessReviewSource{}, worker.ErrNoTask } - return coredata.AccessSource{}, err + return coredata.AccessReviewSource{}, err } return source, nil } -func (h *sourceNameHandler) Process(ctx context.Context, source coredata.AccessSource) error { +func (h *sourceNameHandler) Process(ctx context.Context, source coredata.AccessReviewSource) error { h.logger.InfoCtx( ctx, "syncing source name", @@ -206,7 +206,7 @@ func (h *sourceNameHandler) Process(ctx context.Context, source coredata.AccessS func (h *sourceNameHandler) markNameSynced( ctx context.Context, - source *coredata.AccessSource, + source *coredata.AccessReviewSource, ) error { return h.pg.WithTx( ctx, diff --git a/pkg/accessreview/access_source_service.go b/pkg/accessreview/source_service.go similarity index 68% rename from pkg/accessreview/access_source_service.go rename to pkg/accessreview/source_service.go index 96ff7547f..4c17bfc9d 100644 --- a/pkg/accessreview/access_source_service.go +++ b/pkg/accessreview/source_service.go @@ -22,9 +22,7 @@ import ( "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/connector" - "go.probo.inc/probo/pkg/connector/provider" "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/crypto/cipher" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/validator" @@ -35,76 +33,69 @@ const ( ) type ( - AccessSourceService struct { - pg *pg.Client - scope coredata.Scoper - encryptionKey cipher.EncryptionKey - connectorRegistry *connector.ConnectorRegistry - providerRegistry *provider.Registry - } - - CreateAccessSourceRequest struct { + CreateAccessReviewSourceRequest struct { OrganizationID gid.GID ConnectorID *gid.GID Name string - Category coredata.AccessSourceCategory + Category coredata.AccessReviewSourceCategory CsvData *string } - UpdateAccessSourceRequest struct { - AccessSourceID gid.GID - Name *string - Category *coredata.AccessSourceCategory - ConnectorID **gid.GID - CsvData **string + UpdateAccessReviewSourceRequest struct { + AccessReviewSourceID gid.GID + Name *string + Category *coredata.AccessReviewSourceCategory + ConnectorID **gid.GID + CsvData **string } - ConfigureAccessSourceRequest struct { - AccessSourceID gid.GID - OrganizationSlug string + ConfigureAccessReviewSourceRequest struct { + AccessReviewSourceID gid.GID + OrganizationSlug string } ) -func (r *CreateAccessSourceRequest) Validate() error { +func (r *CreateAccessReviewSourceRequest) Validate() error { v := validator.New() v.Check(r.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType)) v.Check(r.Name, "name", validator.SafeTextNoNewLine(NameMaxLength)) - v.Check(r.Category, "category", validator.OneOfSlice(coredata.AccessSourceCategories())) + v.Check(r.Category, "category", validator.OneOfSlice(coredata.AccessReviewSourceCategories())) return v.Error() } -func (r *ConfigureAccessSourceRequest) Validate() error { +func (r *ConfigureAccessReviewSourceRequest) Validate() error { v := validator.New() - v.Check(r.AccessSourceID, "access_source_id", validator.Required(), validator.GID(coredata.AccessSourceEntityType)) + v.Check(r.AccessReviewSourceID, "access_review_source_id", validator.Required(), validator.GID(coredata.AccessReviewSourceEntityType)) v.Check(r.OrganizationSlug, "organization_slug", validator.Required()) return v.Error() } -func (r *UpdateAccessSourceRequest) Validate() error { +func (r *UpdateAccessReviewSourceRequest) Validate() error { v := validator.New() - v.Check(r.AccessSourceID, "access_source_id", validator.Required(), validator.GID(coredata.AccessSourceEntityType)) + v.Check(r.AccessReviewSourceID, "access_review_source_id", validator.Required(), validator.GID(coredata.AccessReviewSourceEntityType)) v.Check(r.Name, "name", validator.SafeTextNoNewLine(NameMaxLength)) - v.Check(r.Category, "category", validator.OneOfSlice(coredata.AccessSourceCategories())) + v.Check(r.Category, "category", validator.OneOfSlice(coredata.AccessReviewSourceCategories())) return v.Error() } -func (s AccessSourceService) Create( +func (s *Service) CreateSource( ctx context.Context, - req CreateAccessSourceRequest, -) (*coredata.AccessSource, error) { + scope coredata.Scoper, + req CreateAccessReviewSourceRequest, +) (*coredata.AccessReviewSource, error) { if err := req.Validate(); err != nil { return nil, err } now := time.Now() - source := &coredata.AccessSource{ - ID: gid.New(s.scope.GetTenantID(), coredata.AccessSourceEntityType), + source := &coredata.AccessReviewSource{ + ID: gid.New(scope.GetTenantID(), coredata.AccessReviewSourceEntityType), OrganizationID: req.OrganizationID, ConnectorID: req.ConnectorID, Name: req.Name, @@ -120,12 +111,12 @@ func (s AccessSourceService) Create( // Validate connector exists if provided if req.ConnectorID != nil { connector := &coredata.Connector{} - if err := connector.LoadMetadataByID(ctx, conn, s.scope, *req.ConnectorID); err != nil { + if err := connector.LoadMetadataByID(ctx, conn, scope, *req.ConnectorID); err != nil { return fmt.Errorf("cannot load connector: %w", err) } } - if err := source.Insert(ctx, conn, s.scope); err != nil { + if err := source.Insert(ctx, conn, scope); err != nil { return fmt.Errorf("cannot insert access source: %w", err) } @@ -139,16 +130,17 @@ func (s AccessSourceService) Create( return source, nil } -func (s AccessSourceService) Get( +func (s *Service) GetSource( ctx context.Context, + scope coredata.Scoper, accessSourceID gid.GID, -) (*coredata.AccessSource, error) { - source := &coredata.AccessSource{} +) (*coredata.AccessReviewSource, error) { + source := &coredata.AccessReviewSource{} err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - return source.LoadByID(ctx, conn, s.scope, accessSourceID) + return source.LoadByID(ctx, conn, scope, accessSourceID) }, ) if err != nil { @@ -158,20 +150,21 @@ func (s AccessSourceService) Get( return source, nil } -func (s AccessSourceService) Update( +func (s *Service) UpdateSource( ctx context.Context, - req UpdateAccessSourceRequest, -) (*coredata.AccessSource, error) { + scope coredata.Scoper, + req UpdateAccessReviewSourceRequest, +) (*coredata.AccessReviewSource, error) { if err := req.Validate(); err != nil { return nil, err } - source := &coredata.AccessSource{} + source := &coredata.AccessReviewSource{} err := s.pg.WithTx( ctx, func(ctx context.Context, conn pg.Tx) error { - if err := source.LoadByID(ctx, conn, s.scope, req.AccessSourceID); err != nil { + if err := source.LoadByID(ctx, conn, scope, req.AccessReviewSourceID); err != nil { return fmt.Errorf("cannot load access source: %w", err) } @@ -186,7 +179,7 @@ func (s AccessSourceService) Update( if req.ConnectorID != nil { if *req.ConnectorID != nil { connector := &coredata.Connector{} - if err := connector.LoadMetadataByID(ctx, conn, s.scope, **req.ConnectorID); err != nil { + if err := connector.LoadMetadataByID(ctx, conn, scope, **req.ConnectorID); err != nil { return fmt.Errorf("cannot load connector: %w", err) } } @@ -200,7 +193,7 @@ func (s AccessSourceService) Update( source.UpdatedAt = time.Now() - if err := source.Update(ctx, conn, s.scope); err != nil { + if err := source.Update(ctx, conn, scope); err != nil { return fmt.Errorf("cannot update access source: %w", err) } @@ -214,20 +207,21 @@ func (s AccessSourceService) Update( return source, nil } -func (s AccessSourceService) Delete( +func (s *Service) DeleteSource( ctx context.Context, + scope coredata.Scoper, accessSourceID gid.GID, ) error { - source := &coredata.AccessSource{} + source := &coredata.AccessReviewSource{} return s.pg.WithTx( ctx, func(ctx context.Context, conn pg.Tx) error { - if err := source.LoadByID(ctx, conn, s.scope, accessSourceID); err != nil { + if err := source.LoadByID(ctx, conn, scope, accessSourceID); err != nil { return fmt.Errorf("cannot load access source: %w", err) } - if err := source.Delete(ctx, conn, s.scope); err != nil { + if err := source.Delete(ctx, conn, scope); err != nil { return fmt.Errorf("cannot delete access source: %w", err) } @@ -239,9 +233,9 @@ func (s AccessSourceService) Delete( return nil } - accessSources := &coredata.AccessSources{} + accessSources := &coredata.AccessReviewSources{} - sourceCount, err := accessSources.CountByConnectorID(ctx, conn, s.scope, *source.ConnectorID) + sourceCount, err := accessSources.CountByConnectorID(ctx, conn, scope, *source.ConnectorID) if err != nil { return fmt.Errorf("cannot count access sources for connector: %w", err) } @@ -252,7 +246,7 @@ func (s AccessSourceService) Delete( bridges := &coredata.SCIMBridges{} - bridgeCount, err := bridges.CountByConnectorID(ctx, conn, s.scope, *source.ConnectorID) + bridgeCount, err := bridges.CountByConnectorID(ctx, conn, scope, *source.ConnectorID) if err != nil { return fmt.Errorf("cannot count scim bridges for connector: %w", err) } @@ -272,7 +266,7 @@ func (s AccessSourceService) Delete( ctx, func(ctx context.Context, conn pg.Tx) error { cnnctr := &coredata.Connector{ID: *source.ConnectorID} - if err := cnnctr.Delete(ctx, conn, s.scope); err != nil { + if err := cnnctr.Delete(ctx, conn, scope); err != nil { return fmt.Errorf("cannot delete connector: %w", err) } @@ -287,17 +281,18 @@ func (s AccessSourceService) Delete( ) } -func (s AccessSourceService) ListForOrganizationID( +func (s *Service) ListSourcesForOrganizationID( ctx context.Context, + scope coredata.Scoper, organizationID gid.GID, - cursor *page.Cursor[coredata.AccessSourceOrderField], -) (*page.Page[*coredata.AccessSource, coredata.AccessSourceOrderField], error) { - var sources coredata.AccessSources + cursor *page.Cursor[coredata.AccessReviewSourceOrderField], +) (*page.Page[*coredata.AccessReviewSource, coredata.AccessReviewSourceOrderField], error) { + var sources coredata.AccessReviewSources err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - return sources.LoadByOrganizationID(ctx, conn, s.scope, organizationID, cursor) + return sources.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor) }, ) if err != nil { @@ -307,8 +302,9 @@ func (s AccessSourceService) ListForOrganizationID( return page.NewPage(sources, cursor), nil } -func (s AccessSourceService) CountForOrganizationID( +func (s *Service) CountSourcesForOrganizationID( ctx context.Context, + scope coredata.Scoper, organizationID gid.GID, ) (int, error) { var count int @@ -316,8 +312,8 @@ func (s AccessSourceService) CountForOrganizationID( err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) (err error) { - sources := coredata.AccessSources{} - count, err = sources.CountByOrganizationID(ctx, conn, s.scope, organizationID) + sources := coredata.AccessReviewSources{} + count, err = sources.CountByOrganizationID(ctx, conn, scope, organizationID) return err }, @@ -329,30 +325,12 @@ func (s AccessSourceService) CountForOrganizationID( return count, nil } -func (s AccessSourceService) ListScopeSourcesForCampaignID( - ctx context.Context, - campaignID gid.GID, -) ([]*coredata.AccessSource, error) { - var sources coredata.AccessSources - - err := s.pg.WithConn( - ctx, - func(ctx context.Context, conn pg.Querier) error { - return sources.LoadScopeSourcesByCampaignID(ctx, conn, s.scope, campaignID) - }, - ) - if err != nil { - return nil, fmt.Errorf("cannot list scope sources: %w", err) - } - - return sources, nil -} - // ConnectorHTTPClient loads a connector by ID with decrypted credentials // and returns an HTTP client with token refresh support. If the token was // refreshed during client creation, the updated credentials are persisted. -func (s AccessSourceService) ConnectorHTTPClient( +func (s *Service) ConnectorHTTPClient( ctx context.Context, + scope coredata.Scoper, connectorID gid.GID, ) (*http.Client, *coredata.Connector, error) { var dbConnector coredata.Connector @@ -360,7 +338,7 @@ func (s AccessSourceService) ConnectorHTTPClient( err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - if err := dbConnector.LoadByID(ctx, conn, s.scope, connectorID, s.encryptionKey); err != nil { + if err := dbConnector.LoadByID(ctx, conn, scope, connectorID, s.encryptionKey); err != nil { return fmt.Errorf("cannot load connector: %w", err) } @@ -408,7 +386,7 @@ func (s AccessSourceService) ConnectorHTTPClient( if err := s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { - return dbConnector.Update(ctx, tx, s.scope, s.encryptionKey) + return dbConnector.Update(ctx, tx, scope, s.encryptionKey) }, ); err != nil { return nil, nil, fmt.Errorf("cannot persist refreshed token: %w", err) @@ -418,20 +396,21 @@ func (s AccessSourceService) ConnectorHTTPClient( return httpClient, &dbConnector, nil } -func (s AccessSourceService) ConfigureAccessSource( +func (s *Service) ConfigureAccessReviewSource( ctx context.Context, - req ConfigureAccessSourceRequest, -) (*coredata.AccessSource, error) { + scope coredata.Scoper, + req ConfigureAccessReviewSourceRequest, +) (*coredata.AccessReviewSource, error) { if err := req.Validate(); err != nil { return nil, err } - source := &coredata.AccessSource{} + source := &coredata.AccessReviewSource{} err := s.pg.WithTx( ctx, func(ctx context.Context, conn pg.Tx) error { - if err := source.LoadByID(ctx, conn, s.scope, req.AccessSourceID); err != nil { + if err := source.LoadByID(ctx, conn, scope, req.AccessReviewSourceID); err != nil { return fmt.Errorf("cannot load access source: %w", err) } @@ -440,7 +419,7 @@ func (s AccessSourceService) ConfigureAccessSource( } dbConnector := &coredata.Connector{} - if err := dbConnector.LoadByID(ctx, conn, s.scope, *source.ConnectorID, s.encryptionKey); err != nil { + if err := dbConnector.LoadByID(ctx, conn, scope, *source.ConnectorID, s.encryptionKey); err != nil { return fmt.Errorf("cannot load connector: %w", err) } @@ -455,7 +434,7 @@ func (s AccessSourceService) ConfigureAccessSource( dbConnector.UpdatedAt = time.Now() - if err := dbConnector.Update(ctx, conn, s.scope, s.encryptionKey); err != nil { + if err := dbConnector.Update(ctx, conn, scope, s.encryptionKey); err != nil { return fmt.Errorf("cannot update connector: %w", err) } diff --git a/pkg/accessreview/worker.go b/pkg/accessreview/worker.go index 457dcef17..9abb97155 100644 --- a/pkg/accessreview/worker.go +++ b/pkg/accessreview/worker.go @@ -27,6 +27,11 @@ import ( "go.probo.inc/probo/pkg/gid" ) +// sourceFetchFailureMessage is the generic, user-facing message persisted on a +// failed fetch attempt. The raw error is only ever written to the logs so that +// internal connector details are never surfaced through the API or UI. +const sourceFetchFailureMessage = "We couldn't fetch accounts from this source. Verify the source configuration and try again." + type sourceFetchHandler struct { svc *Service pg *pg.Client @@ -39,7 +44,7 @@ func NewSourceFetchWorker( pgClient *pg.Client, logger *log.Logger, opts ...worker.Option, -) *worker.Worker[coredata.AccessReviewCampaignSourceFetch] { +) *worker.Worker[coredata.AccessReviewCampaignSourceFetchAttempt] { h := &sourceFetchHandler{ svc: svc, pg: pgClient, @@ -55,44 +60,43 @@ func NewSourceFetchWorker( ) } -func (h *sourceFetchHandler) Claim(ctx context.Context) (coredata.AccessReviewCampaignSourceFetch, error) { - var sourceFetch coredata.AccessReviewCampaignSourceFetch +func (h *sourceFetchHandler) Claim(ctx context.Context) (coredata.AccessReviewCampaignSourceFetchAttempt, error) { + var attempt coredata.AccessReviewCampaignSourceFetchAttempt if err := h.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { - if err := sourceFetch.LoadNextQueuedForUpdateSkipLocked(ctx, tx); err != nil { + if err := attempt.LoadNextQueuedForUpdateSkipLocked(ctx, tx); err != nil { return err } now := time.Now() - sourceFetch.Status = coredata.AccessReviewCampaignSourceFetchStatusFetching - sourceFetch.AttemptCount++ - sourceFetch.LastError = nil - sourceFetch.StartedAt = new(now) - sourceFetch.CompletedAt = nil - sourceFetch.UpdatedAt = now + attempt.Status = coredata.AccessReviewCampaignSourceFetchStatusFetching + attempt.Error = nil + attempt.StartedAt = &now + attempt.CompletedAt = nil + attempt.UpdatedAt = now - scope := coredata.NewScope(sourceFetch.TenantID) - if err := sourceFetch.Update(ctx, tx, scope); err != nil { - return fmt.Errorf("cannot update source fetch status: %w", err) + scope := coredata.NewScope(attempt.TenantID) + if err := attempt.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update fetch attempt status: %w", err) } return nil }, ); err != nil { - if errors.Is(err, coredata.ErrNoAccessReviewCampaignSourceFetchAvailable) { - return coredata.AccessReviewCampaignSourceFetch{}, worker.ErrNoTask + if errors.Is(err, coredata.ErrNoAccessReviewCampaignSourceFetchAttemptAvailable) { + return coredata.AccessReviewCampaignSourceFetchAttempt{}, worker.ErrNoTask } - return coredata.AccessReviewCampaignSourceFetch{}, fmt.Errorf("cannot claim source fetch: %w", err) + return coredata.AccessReviewCampaignSourceFetchAttempt{}, fmt.Errorf("cannot claim fetch attempt: %w", err) } - return sourceFetch, nil + return attempt, nil } -func (h *sourceFetchHandler) Process(ctx context.Context, sourceFetch coredata.AccessReviewCampaignSourceFetch) error { - return h.handle(ctx, &sourceFetch) +func (h *sourceFetchHandler) Process(ctx context.Context, attempt coredata.AccessReviewCampaignSourceFetchAttempt) error { + return h.handle(ctx, &attempt) } func (h *sourceFetchHandler) RecoverStale(ctx context.Context) error { @@ -102,18 +106,18 @@ func (h *sourceFetchHandler) RecoverStale(ctx context.Context) error { return h.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { - var fetches coredata.AccessReviewCampaignSourceFetches + var attempts coredata.AccessReviewCampaignSourceFetchAttempts - count, err := fetches.RecoverStale(ctx, tx, staleThreshold, now) + count, err := attempts.RecoverStale(ctx, tx, staleThreshold, now) if err != nil { - return fmt.Errorf("cannot recover stale source fetches: %w", err) + return fmt.Errorf("cannot recover stale fetch attempts: %w", err) } if count > 0 { h.logger.InfoCtx( ctx, - "recovered stale source fetches", - log.Int64("count", count), + "recovered stale fetch attempts", + log.Int("count", count), ) } @@ -124,101 +128,123 @@ func (h *sourceFetchHandler) RecoverStale(ctx context.Context) error { func (h *sourceFetchHandler) handle( ctx context.Context, - sourceFetch *coredata.AccessReviewCampaignSourceFetch, + attempt *coredata.AccessReviewCampaignSourceFetchAttempt, ) error { - scope := coredata.NewScope(sourceFetch.TenantID) + scope := coredata.NewScope(attempt.TenantID) - campaign, err := h.svc.Campaigns(scope).Get(ctx, sourceFetch.AccessReviewCampaignID) - if err != nil { - commitErr := h.commitFailedSourceFetch( - ctx, - sourceFetch, - fmt.Errorf("cannot load campaign: %w", err), - ) + campaignSource := &coredata.AccessReviewCampaignSource{} + if err := h.loadCampaignSource(ctx, scope, attempt.AccessReviewCampaignSourceID, campaignSource); err != nil { + commitErr := h.commitFailedSourceFetch(ctx, attempt, fmt.Errorf("cannot load campaign source: %w", err)) if commitErr != nil { - return fmt.Errorf("cannot load campaign: %w, and cannot commit failed source fetch: %w", err, commitErr) + return fmt.Errorf("cannot load campaign source: %w, and cannot commit failed fetch attempt: %w", err, commitErr) + } + + return fmt.Errorf("cannot load campaign source: %w", err) + } + + campaign, err := h.svc.GetCampaign(ctx, scope, campaignSource.AccessReviewCampaignID) + if err != nil { + commitErr := h.commitFailedSourceFetch(ctx, attempt, fmt.Errorf("cannot load campaign: %w", err)) + if commitErr != nil { + return fmt.Errorf("cannot load campaign: %w, and cannot commit failed fetch attempt: %w", err, commitErr) } return fmt.Errorf("cannot load campaign: %w", err) } - count, err := h.svc.Engine(scope).FetchSource(ctx, campaign, sourceFetch.AccessSourceID) + count, err := h.svc.FetchSource(ctx, scope, campaign, campaignSource) if err != nil { - commitErr := h.commitFailedSourceFetch(ctx, sourceFetch, err) - if commitErr != nil { - return fmt.Errorf("cannot fetch source: %w, and cannot commit failed source fetch: %w", err, commitErr) + if commitErr := h.commitFailedSourceFetch(ctx, attempt, err); commitErr != nil { + return fmt.Errorf("cannot fetch source: %w, and cannot commit failed fetch attempt: %w", err, commitErr) } - if finalizeErr := h.finalizeCampaignFetchLifecycle(ctx, sourceFetch.TenantID, sourceFetch.AccessReviewCampaignID); finalizeErr != nil { - return fmt.Errorf("cannot finalize campaign after failed source fetch: %w", finalizeErr) + if finalizeErr := h.finalizeCampaignFetchLifecycle(ctx, attempt.TenantID, campaignSource.AccessReviewCampaignID); finalizeErr != nil { + return fmt.Errorf("cannot finalize campaign after failed fetch attempt: %w", finalizeErr) } - h.logger.WarnCtx( - ctx, - "source fetch failed but campaign can continue", - log.String("campaign_id", sourceFetch.AccessReviewCampaignID.String()), - log.String("access_source_id", sourceFetch.AccessSourceID.String()), - log.Error(err), - ) - return nil } - if err := h.commitSuccessfulSourceFetch(ctx, sourceFetch, count); err != nil { - return fmt.Errorf("cannot commit successful source fetch: %w", err) + if err := h.commitSuccessfulSourceFetch(ctx, attempt, count); err != nil { + return fmt.Errorf("cannot commit successful fetch attempt: %w", err) } - if err := h.finalizeCampaignFetchLifecycle(ctx, sourceFetch.TenantID, sourceFetch.AccessReviewCampaignID); err != nil { + if err := h.finalizeCampaignFetchLifecycle(ctx, attempt.TenantID, campaignSource.AccessReviewCampaignID); err != nil { return fmt.Errorf("cannot finalize campaign fetch lifecycle: %w", err) } return nil } +func (h *sourceFetchHandler) loadCampaignSource( + ctx context.Context, + scope coredata.Scoper, + campaignSourceID gid.GID, + campaignSource *coredata.AccessReviewCampaignSource, +) error { + return h.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + return campaignSource.LoadByID(ctx, conn, scope, campaignSourceID) + }, + ) +} + +// commitFailedSourceFetch marks the in-flight attempt as failed with a generic, +// user-facing message and logs the raw error so the internal detail stays in the +// logs only. func (h *sourceFetchHandler) commitFailedSourceFetch( ctx context.Context, - sourceFetch *coredata.AccessReviewCampaignSourceFetch, + attempt *coredata.AccessReviewCampaignSourceFetchAttempt, failureErr error, ) error { - var ( - now = time.Now() - errMsg = failureErr.Error() - scope = coredata.NewScopeFromObjectID(sourceFetch.AccessReviewCampaignID) + h.logger.WarnCtx( + ctx, + "source fetch failed but campaign can continue", + log.String("access_review_campaign_source_id", attempt.AccessReviewCampaignSourceID.String()), + log.String("fetch_attempt_id", attempt.ID.String()), + log.Error(failureErr), ) - sourceFetch.Status = coredata.AccessReviewCampaignSourceFetchStatusFailed - sourceFetch.LastError = &errMsg - sourceFetch.CompletedAt = new(now) - sourceFetch.UpdatedAt = now + var ( + now = time.Now() + errMsg = sourceFetchFailureMessage + scope = coredata.NewScope(attempt.TenantID) + ) + + attempt.Status = coredata.AccessReviewCampaignSourceFetchStatusFailed + attempt.Error = &errMsg + attempt.CompletedAt = &now + attempt.UpdatedAt = now return h.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { - return sourceFetch.Update(ctx, tx, scope) + return attempt.Update(ctx, tx, scope) }, ) } func (h *sourceFetchHandler) commitSuccessfulSourceFetch( ctx context.Context, - sourceFetch *coredata.AccessReviewCampaignSourceFetch, + attempt *coredata.AccessReviewCampaignSourceFetchAttempt, fetchedAccountsCount int, ) error { var ( now = time.Now() - scope = coredata.NewScopeFromObjectID(sourceFetch.AccessReviewCampaignID) + scope = coredata.NewScope(attempt.TenantID) ) - sourceFetch.Status = coredata.AccessReviewCampaignSourceFetchStatusSuccess - sourceFetch.FetchedAccountsCount = fetchedAccountsCount - sourceFetch.LastError = nil - sourceFetch.CompletedAt = new(now) - sourceFetch.UpdatedAt = now + attempt.Status = coredata.AccessReviewCampaignSourceFetchStatusSuccess + attempt.FetchedAccountsCount = fetchedAccountsCount + attempt.Error = nil + attempt.CompletedAt = &now + attempt.UpdatedAt = now return h.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { - return sourceFetch.Update(ctx, tx, scope) + return attempt.Update(ctx, tx, scope) }, ) } @@ -246,17 +272,17 @@ func (h *sourceFetchHandler) finalizeCampaignFetchLifecycle( return nil } - fetches := coredata.AccessReviewCampaignSourceFetches{} - if err := fetches.LoadByCampaignID(ctx, tx, scope, campaignID); err != nil { - return fmt.Errorf("cannot load source fetches: %w", err) + latest := coredata.AccessReviewCampaignSourceFetchAttempts{} + if err := latest.LoadLatestByCampaignID(ctx, tx, scope, campaignID); err != nil { + return fmt.Errorf("cannot load latest fetch attempts: %w", err) } - if len(fetches) == 0 { + if len(latest) == 0 { return nil } - for _, fetch := range fetches { - if !fetch.Status.IsTerminal() { + for _, attempt := range latest { + if !attempt.Status.IsTerminal() { return nil } } diff --git a/pkg/cmd/access-review/campaign/addsource/addsource.go b/pkg/cmd/access-review/campaign/addsource/addsource.go index d0126eab9..5fa30c4b2 100644 --- a/pkg/cmd/access-review/campaign/addsource/addsource.go +++ b/pkg/cmd/access-review/campaign/addsource/addsource.go @@ -24,8 +24,8 @@ import ( ) const addSourceMutation = ` -mutation($input: AddAccessReviewCampaignScopeSourceInput!) { - addAccessReviewCampaignScopeSource(input: $input) { +mutation($input: AddAccessReviewCampaignSourceInput!) { + addAccessReviewCampaignSource(input: $input) { accessReviewCampaign { id name @@ -36,13 +36,13 @@ mutation($input: AddAccessReviewCampaignScopeSourceInput!) { ` type addSourceResponse struct { - AddAccessReviewCampaignScopeSource struct { + AddAccessReviewCampaignSource struct { AccessReviewCampaign struct { ID string `json:"id"` Name string `json:"name"` Status string `json:"status"` } `json:"accessReviewCampaign"` - } `json:"addAccessReviewCampaignScopeSource"` + } `json:"addAccessReviewCampaignSource"` } func NewCmdAddSource(f *cmdutil.Factory) *cobra.Command { @@ -73,7 +73,7 @@ func NewCmdAddSource(f *cmdutil.Factory) *cobra.Command { input := map[string]any{ "accessReviewCampaignId": args[0], - "accessSourceId": flagSourceID, + "accessReviewSourceId": flagSourceID, } data, err := client.Do( @@ -89,7 +89,7 @@ func NewCmdAddSource(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("cannot parse response: %w", err) } - c := resp.AddAccessReviewCampaignScopeSource.AccessReviewCampaign + c := resp.AddAccessReviewCampaignSource.AccessReviewCampaign out := f.IOStreams.Out _, _ = fmt.Fprintf(out, "Added source %s to campaign %s\n", flagSourceID, c.ID) diff --git a/pkg/cmd/access-review/campaign/create/create.go b/pkg/cmd/access-review/campaign/create/create.go index 26ba0bf4d..3864465e8 100644 --- a/pkg/cmd/access-review/campaign/create/create.go +++ b/pkg/cmd/access-review/campaign/create/create.go @@ -98,7 +98,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { } if len(flagSourceIDs) > 0 { - input["accessSourceIds"] = flagSourceIDs + input["accessReviewSourceIds"] = flagSourceIDs } data, err := client.Do( diff --git a/pkg/cmd/access-review/campaign/removesource/removesource.go b/pkg/cmd/access-review/campaign/removesource/removesource.go index f22226f5b..054d2c040 100644 --- a/pkg/cmd/access-review/campaign/removesource/removesource.go +++ b/pkg/cmd/access-review/campaign/removesource/removesource.go @@ -24,8 +24,8 @@ import ( ) const removeSourceMutation = ` -mutation($input: RemoveAccessReviewCampaignScopeSourceInput!) { - removeAccessReviewCampaignScopeSource(input: $input) { +mutation($input: RemoveAccessReviewCampaignSourceInput!) { + removeAccessReviewCampaignSource(input: $input) { accessReviewCampaign { id name @@ -36,13 +36,13 @@ mutation($input: RemoveAccessReviewCampaignScopeSourceInput!) { ` type removeSourceResponse struct { - RemoveAccessReviewCampaignScopeSource struct { + RemoveAccessReviewCampaignSource struct { AccessReviewCampaign struct { ID string `json:"id"` Name string `json:"name"` Status string `json:"status"` } `json:"accessReviewCampaign"` - } `json:"removeAccessReviewCampaignScopeSource"` + } `json:"removeAccessReviewCampaignSource"` } func NewCmdRemoveSource(f *cmdutil.Factory) *cobra.Command { @@ -73,7 +73,7 @@ func NewCmdRemoveSource(f *cmdutil.Factory) *cobra.Command { input := map[string]any{ "accessReviewCampaignId": args[0], - "accessSourceId": flagSourceID, + "accessReviewSourceId": flagSourceID, } data, err := client.Do( @@ -89,7 +89,7 @@ func NewCmdRemoveSource(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("cannot parse response: %w", err) } - c := resp.RemoveAccessReviewCampaignScopeSource.AccessReviewCampaign + c := resp.RemoveAccessReviewCampaignSource.AccessReviewCampaign out := f.IOStreams.Out _, _ = fmt.Fprintf(out, "Removed source %s from campaign %s\n", flagSourceID, c.ID) diff --git a/pkg/cmd/access-review/entry/decide/decide.go b/pkg/cmd/access-review/entry/decide/decide.go index d4a491021..5c00f4a04 100644 --- a/pkg/cmd/access-review/entry/decide/decide.go +++ b/pkg/cmd/access-review/entry/decide/decide.go @@ -24,8 +24,8 @@ import ( ) const decideMutation = ` -mutation($input: RecordAccessEntryDecisionInput!) { - recordAccessEntryDecision(input: $input) { +mutation($input: RecordAccessReviewEntryDecisionInput!) { + recordAccessReviewEntryDecision(input: $input) { accessEntry { id email @@ -39,8 +39,8 @@ mutation($input: RecordAccessEntryDecisionInput!) { ` type decideResponse struct { - RecordAccessEntryDecision struct { - AccessEntry struct { + RecordAccessReviewEntryDecision struct { + AccessReviewEntry struct { ID string `json:"id"` Email string `json:"email"` FullName string `json:"fullName"` @@ -48,7 +48,7 @@ type decideResponse struct { DecisionNote *string `json:"decisionNote"` DecidedAt *string `json:"decidedAt"` } `json:"accessEntry"` - } `json:"recordAccessEntryDecision"` + } `json:"recordAccessReviewEntryDecision"` } func NewCmdDecide(f *cmdutil.Factory) *cobra.Command { @@ -102,8 +102,8 @@ func NewCmdDecide(f *cmdutil.Factory) *cobra.Command { ) input := map[string]any{ - "accessEntryId": args[0], - "decision": flagDecision, + "accessReviewEntryId": args[0], + "decision": flagDecision, } if flagNote != "" { input["decisionNote"] = flagNote @@ -122,7 +122,7 @@ func NewCmdDecide(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("cannot parse response: %w", err) } - e := resp.RecordAccessEntryDecision.AccessEntry + e := resp.RecordAccessReviewEntryDecision.AccessReviewEntry if *flagOutput == cmdutil.OutputJSON { return cmdutil.PrintJSON(f.IOStreams.Out, e) diff --git a/pkg/cmd/access-review/entry/decideall/decideall.go b/pkg/cmd/access-review/entry/decideall/decideall.go index 09ba98a75..8e75750ae 100644 --- a/pkg/cmd/access-review/entry/decideall/decideall.go +++ b/pkg/cmd/access-review/entry/decideall/decideall.go @@ -24,8 +24,8 @@ import ( ) const decideAllMutation = ` -mutation($input: RecordAccessEntryDecisionsInput!) { - recordAccessEntryDecisions(input: $input) { +mutation($input: RecordAccessReviewEntryDecisionsInput!) { + recordAccessReviewEntryDecisions(input: $input) { accessEntries { id email @@ -36,13 +36,13 @@ mutation($input: RecordAccessEntryDecisionsInput!) { ` type decideAllResponse struct { - RecordAccessEntryDecisions struct { - AccessEntries []struct { + RecordAccessReviewEntryDecisions struct { + AccessReviewEntries []struct { ID string `json:"id"` Email string `json:"email"` Decision string `json:"decision"` } `json:"accessEntries"` - } `json:"recordAccessEntryDecisions"` + } `json:"recordAccessReviewEntryDecisions"` } func NewCmdDecideAll(f *cmdutil.Factory) *cobra.Command { @@ -96,8 +96,8 @@ func NewCmdDecideAll(f *cmdutil.Factory) *cobra.Command { decisions := make([]map[string]any, len(flagEntryIDs)) for i, id := range flagEntryIDs { d := map[string]any{ - "accessEntryId": id, - "decision": flagDecision, + "accessReviewEntryId": id, + "decision": flagDecision, } if flagNote != "" { d["decisionNote"] = flagNote @@ -119,7 +119,7 @@ func NewCmdDecideAll(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("cannot parse response: %w", err) } - entries := resp.RecordAccessEntryDecisions.AccessEntries + entries := resp.RecordAccessReviewEntryDecisions.AccessReviewEntries if *flagOutput == cmdutil.OutputJSON { return cmdutil.PrintJSON(f.IOStreams.Out, entries) diff --git a/pkg/cmd/access-review/entry/list/list.go b/pkg/cmd/access-review/entry/list/list.go index 22fc5bf52..c24d9f034 100644 --- a/pkg/cmd/access-review/entry/list/list.go +++ b/pkg/cmd/access-review/entry/list/list.go @@ -29,9 +29,9 @@ query( $id: ID!, $first: Int, $after: CursorKey, - $orderBy: AccessEntryOrder, - $accessSourceId: ID, - $filter: AccessEntryFilter + $orderBy: AccessReviewEntryOrder, + $campaignSourceId: ID, + $filter: AccessReviewEntryFilter ) { node(id: $id) { __typename @@ -40,7 +40,7 @@ query( first: $first, after: $after, orderBy: $orderBy, - accessSourceId: $accessSourceId, + campaignSourceId: $campaignSourceId, filter: $filter ) { totalCount @@ -81,24 +81,24 @@ query( ` type entryNode struct { - ID string `json:"id"` - Email string `json:"email"` - FullName string `json:"fullName"` - Role string `json:"role"` - JobTitle string `json:"jobTitle"` - IsAdmin bool `json:"isAdmin"` - Active *bool `json:"active"` - MfaStatus string `json:"mfaStatus"` - AuthMethod string `json:"authMethod"` - AccountType string `json:"accountType"` - LastLogin *string `json:"lastLogin"` - ExternalID string `json:"externalId"` - IncrementalTag string `json:"incrementalTag"` - Flags []string `json:"flags"` - FlagReasons []string `json:"flagReasons"` - Decision string `json:"decision"` - DecisionNote *string `json:"decisionNote"` - AccessSource struct { + ID string `json:"id"` + Email string `json:"email"` + FullName string `json:"fullName"` + Role string `json:"role"` + JobTitle string `json:"jobTitle"` + IsAdmin bool `json:"isAdmin"` + Active *bool `json:"active"` + MfaStatus string `json:"mfaStatus"` + AuthMethod string `json:"authMethod"` + AccountType string `json:"accountType"` + LastLogin *string `json:"lastLogin"` + ExternalID string `json:"externalId"` + IncrementalTag string `json:"incrementalTag"` + Flags []string `json:"flags"` + FlagReasons []string `json:"flagReasons"` + Decision string `json:"decision"` + DecisionNote *string `json:"decisionNote"` + AccessReviewSource struct { ID string `json:"id"` Name string `json:"name"` } `json:"accessSource"` @@ -107,18 +107,18 @@ type entryNode struct { func NewCmdList(f *cmdutil.Factory) *cobra.Command { var ( - flagLimit int - flagOrderBy string - flagOrderDir string - flagSourceID string - flagDecision string - flagFlag string - flagIncTag string - flagIsAdmin *bool - flagActive *bool - flagAuthMethod string - flagAccountType string - flagOutput *string + flagLimit int + flagOrderBy string + flagOrderDir string + flagCampaignSourceID string + flagDecision string + flagFlag string + flagIncTag string + flagIsAdmin *bool + flagActive *bool + flagAuthMethod string + flagAccountType string + flagOutput *string ) cmd := &cobra.Command{ @@ -129,7 +129,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { prb access-review entry list # List entries for a specific source - prb access-review entry list --source-id + prb access-review entry list --campaign-source-id # List only pending entries prb access-review entry list --decision PENDING @@ -178,8 +178,8 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { } } - if flagSourceID != "" { - variables["accessSourceId"] = flagSourceID + if flagCampaignSourceID != "" { + variables["campaignSourceId"] = flagCampaignSourceID } filter := map[string]any{} @@ -326,7 +326,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { e.ID, e.Email, e.FullName, - e.AccessSource.Name, + e.AccessReviewSource.Name, e.Decision, strings.Join(e.Flags, ","), admin, @@ -354,7 +354,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of entries to list") cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT)") cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)") - cmd.Flags().StringVar(&flagSourceID, "source-id", "", "Filter by access source ID") + cmd.Flags().StringVar(&flagCampaignSourceID, "source-id", "", "Filter by access source ID") cmd.Flags().StringVar(&flagDecision, "decision", "", "Filter by decision (PENDING, APPROVED, REVOKE, DEFER, ESCALATE)") cmd.Flags().StringVar(&flagFlag, "flag", "", "Filter by flag (NONE, ORPHANED, INACTIVE, EXCESSIVE, ROLE_MISMATCH, NEW)") cmd.Flags().StringVar(&flagIncTag, "incremental-tag", "", "Filter by incremental tag (NEW, REMOVED, UNCHANGED)") diff --git a/pkg/cmd/access-review/entry/setflag/flag.go b/pkg/cmd/access-review/entry/setflag/flag.go index 562b49129..6279e1f8a 100644 --- a/pkg/cmd/access-review/entry/setflag/flag.go +++ b/pkg/cmd/access-review/entry/setflag/flag.go @@ -25,8 +25,8 @@ import ( ) const flagMutation = ` -mutation($input: FlagAccessEntryInput!) { - flagAccessEntry(input: $input) { +mutation($input: FlagAccessReviewEntryInput!) { + flagAccessReviewEntry(input: $input) { accessEntry { id email @@ -40,8 +40,8 @@ mutation($input: FlagAccessEntryInput!) { ` type flagResponse struct { - FlagAccessEntry struct { - AccessEntry struct { + FlagAccessReviewEntry struct { + AccessReviewEntry struct { ID string `json:"id"` Email string `json:"email"` FullName string `json:"fullName"` @@ -49,7 +49,7 @@ type flagResponse struct { FlagReasons []string `json:"flagReasons"` Decision string `json:"decision"` } `json:"accessEntry"` - } `json:"flagAccessEntry"` + } `json:"flagAccessReviewEntry"` } func NewCmdFlag(f *cmdutil.Factory) *cobra.Command { @@ -107,8 +107,8 @@ func NewCmdFlag(f *cmdutil.Factory) *cobra.Command { ) input := map[string]any{ - "accessEntryId": args[0], - "flags": flagFlags, + "accessReviewEntryId": args[0], + "flags": flagFlags, } if flagReason != "" { input["flagReasons"] = []string{flagReason} @@ -127,7 +127,7 @@ func NewCmdFlag(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("cannot parse response: %w", err) } - e := resp.FlagAccessEntry.AccessEntry + e := resp.FlagAccessReviewEntry.AccessReviewEntry if *flagOutput == cmdutil.OutputJSON { return cmdutil.PrintJSON(f.IOStreams.Out, e) diff --git a/pkg/cmd/access-review/source/create/create.go b/pkg/cmd/access-review/source/create/create.go index 157e6517a..b87946fe9 100644 --- a/pkg/cmd/access-review/source/create/create.go +++ b/pkg/cmd/access-review/source/create/create.go @@ -25,8 +25,8 @@ import ( ) const createMutation = ` -mutation($input: CreateAccessSourceInput!) { - createAccessSource(input: $input) { +mutation($input: CreateAccessReviewSourceInput!) { + createAccessReviewSource(input: $input) { accessSourceEdge { node { id @@ -38,14 +38,14 @@ mutation($input: CreateAccessSourceInput!) { ` type createResponse struct { - CreateAccessSource struct { - AccessSourceEdge struct { + CreateAccessReviewSource struct { + AccessReviewSourceEdge struct { Node struct { ID string `json:"id"` Name string `json:"name"` } `json:"node"` } `json:"accessSourceEdge"` - } `json:"createAccessSource"` + } `json:"createAccessReviewSource"` } func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { @@ -127,7 +127,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("cannot parse response: %w", err) } - s := resp.CreateAccessSource.AccessSourceEdge.Node + s := resp.CreateAccessReviewSource.AccessReviewSourceEdge.Node out := f.IOStreams.Out _, _ = fmt.Fprintf(out, "Created access source %s\n", s.ID) _, _ = fmt.Fprintf(out, "Name: %s\n", s.Name) diff --git a/pkg/cmd/access-review/source/delete/delete.go b/pkg/cmd/access-review/source/delete/delete.go index b67a87335..3915c537d 100644 --- a/pkg/cmd/access-review/source/delete/delete.go +++ b/pkg/cmd/access-review/source/delete/delete.go @@ -24,9 +24,9 @@ import ( ) const deleteMutation = ` -mutation($input: DeleteAccessSourceInput!) { - deleteAccessSource(input: $input) { - deletedAccessSourceId +mutation($input: DeleteAccessReviewSourceInput!) { + deleteAccessReviewSource(input: $input) { + deletedAccessReviewSourceId } } ` @@ -81,7 +81,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { deleteMutation, map[string]any{ "input": map[string]any{ - "accessSourceId": args[0], + "accessReviewSourceId": args[0], }, }, ) diff --git a/pkg/cmd/access-review/source/list/list.go b/pkg/cmd/access-review/source/list/list.go index 74477ab75..869a7db8e 100644 --- a/pkg/cmd/access-review/source/list/list.go +++ b/pkg/cmd/access-review/source/list/list.go @@ -24,11 +24,11 @@ import ( ) const listQuery = ` -query($id: ID!, $first: Int, $after: CursorKey, $orderBy: AccessSourceOrder) { +query($id: ID!, $first: Int, $after: CursorKey, $orderBy: AccessReviewSourceOrder) { node(id: $id) { __typename ... on Organization { - accessSources(first: $first, after: $after, orderBy: $orderBy) { + accessReviewSources(first: $first, after: $after, orderBy: $orderBy) { totalCount edges { node { @@ -125,8 +125,8 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { func(data json.RawMessage) (*api.Connection[sourceNode], error) { var resp struct { Node *struct { - Typename string `json:"__typename"` - AccessSources api.Connection[sourceNode] `json:"accessSources"` + Typename string `json:"__typename"` + AccessReviewSources api.Connection[sourceNode] `json:"accessReviewSources"` } `json:"node"` } if err := json.Unmarshal(data, &resp); err != nil { @@ -141,7 +141,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { return nil, fmt.Errorf("expected Organization node, got %s", resp.Node.Typename) } - return &resp.Node.AccessSources, nil + return &resp.Node.AccessReviewSources, nil }, ) if err != nil { diff --git a/pkg/cmd/access-review/source/update/update.go b/pkg/cmd/access-review/source/update/update.go index ebb5bc31d..515b43303 100644 --- a/pkg/cmd/access-review/source/update/update.go +++ b/pkg/cmd/access-review/source/update/update.go @@ -25,8 +25,8 @@ import ( ) const updateMutation = ` -mutation($input: UpdateAccessSourceInput!) { - updateAccessSource(input: $input) { +mutation($input: UpdateAccessReviewSourceInput!) { + updateAccessReviewSource(input: $input) { accessSource { id name @@ -36,12 +36,12 @@ mutation($input: UpdateAccessSourceInput!) { ` type updateResponse struct { - UpdateAccessSource struct { - AccessSource struct { + UpdateAccessReviewSource struct { + AccessReviewSource struct { ID string `json:"id"` Name string `json:"name"` } `json:"accessSource"` - } `json:"updateAccessSource"` + } `json:"updateAccessReviewSource"` } func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { @@ -80,7 +80,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { ) input := map[string]any{ - "accessSourceId": args[0], + "accessReviewSourceId": args[0], } if cmd.Flags().Changed("name") { @@ -113,7 +113,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("cannot parse response: %w", err) } - s := resp.UpdateAccessSource.AccessSource + s := resp.UpdateAccessReviewSource.AccessReviewSource if *flagOutput == cmdutil.OutputJSON { return cmdutil.PrintJSON(f.IOStreams.Out, s) diff --git a/pkg/cmd/access-review/source/view/view.go b/pkg/cmd/access-review/source/view/view.go index 9d281e752..f21c5e90e 100644 --- a/pkg/cmd/access-review/source/view/view.go +++ b/pkg/cmd/access-review/source/view/view.go @@ -28,7 +28,7 @@ const viewQuery = ` query($id: ID!) { node(id: $id) { __typename - ... on AccessSource { + ... on AccessReviewSource { id name connectorId @@ -97,8 +97,8 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("access source %s not found", args[0]) } - if resp.Node.Typename != "AccessSource" { - return fmt.Errorf("expected AccessSource node, got %s", resp.Node.Typename) + if resp.Node.Typename != "AccessReviewSource" { + return fmt.Errorf("expected AccessReviewSource node, got %s", resp.Node.Typename) } if *flagOutput == cmdutil.OutputJSON { diff --git a/pkg/coredata/access_entry_account_type.go b/pkg/coredata/access_entry_account_type.go deleted file mode 100644 index dc2f6d960..000000000 --- a/pkg/coredata/access_entry_account_type.go +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) 2026 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 ( - "encoding" - "fmt" -) - -type AccessEntryAccountType string - -const ( - AccessEntryAccountTypeUser AccessEntryAccountType = "USER" - AccessEntryAccountTypeServiceAccount AccessEntryAccountType = "SERVICE_ACCOUNT" -) - -var ( - _ fmt.Stringer = AccessEntryAccountType("") - _ encoding.TextMarshaler = AccessEntryAccountType("") - _ encoding.TextUnmarshaler = (*AccessEntryAccountType)(nil) -) - -func AccessEntryAccountTypes() []AccessEntryAccountType { - return []AccessEntryAccountType{ - AccessEntryAccountTypeUser, - AccessEntryAccountTypeServiceAccount, - } -} - -func (v AccessEntryAccountType) IsValid() bool { - switch v { - case - AccessEntryAccountTypeUser, - AccessEntryAccountTypeServiceAccount: - return true - } - - return false -} - -func (v AccessEntryAccountType) String() string { - return string(v) -} - -func (v AccessEntryAccountType) MarshalText() ([]byte, error) { - return []byte(v.String()), nil -} - -func (v *AccessEntryAccountType) UnmarshalText(text []byte) error { - val := AccessEntryAccountType(text) - if !val.IsValid() { - return fmt.Errorf("invalid AccessEntryAccountType value: %q", string(text)) - } - - *v = val - - return nil -} diff --git a/pkg/coredata/access_entry_decision.go b/pkg/coredata/access_entry_decision.go deleted file mode 100644 index 214bd67bc..000000000 --- a/pkg/coredata/access_entry_decision.go +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) 2025-2026 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 ( - "encoding" - "fmt" -) - -type AccessEntryDecision string - -const ( - AccessEntryDecisionPending AccessEntryDecision = "PENDING" - AccessEntryDecisionApproved AccessEntryDecision = "APPROVED" - AccessEntryDecisionRevoke AccessEntryDecision = "REVOKE" - AccessEntryDecisionDefer AccessEntryDecision = "DEFER" - AccessEntryDecisionEscalate AccessEntryDecision = "ESCALATE" -) - -var ( - _ fmt.Stringer = AccessEntryDecision("") - _ encoding.TextMarshaler = AccessEntryDecision("") - _ encoding.TextUnmarshaler = (*AccessEntryDecision)(nil) -) - -func AccessEntryDecisions() []AccessEntryDecision { - return []AccessEntryDecision{ - AccessEntryDecisionPending, - AccessEntryDecisionApproved, - AccessEntryDecisionRevoke, - AccessEntryDecisionDefer, - AccessEntryDecisionEscalate, - } -} - -func (v AccessEntryDecision) IsValid() bool { - switch v { - case - AccessEntryDecisionPending, - AccessEntryDecisionApproved, - AccessEntryDecisionRevoke, - AccessEntryDecisionDefer, - AccessEntryDecisionEscalate: - return true - } - - return false -} - -func (v AccessEntryDecision) String() string { - return string(v) -} - -func (v AccessEntryDecision) MarshalText() ([]byte, error) { - return []byte(v.String()), nil -} - -func (v *AccessEntryDecision) UnmarshalText(text []byte) error { - val := AccessEntryDecision(text) - if !val.IsValid() { - return fmt.Errorf("invalid AccessEntryDecision value: %q", string(text)) - } - - *v = val - - return nil -} diff --git a/pkg/coredata/access_entry_flag.go b/pkg/coredata/access_entry_flag.go deleted file mode 100644 index bd3954d8d..000000000 --- a/pkg/coredata/access_entry_flag.go +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright (c) 2026 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 ( - "encoding" - "fmt" -) - -type AccessEntryFlag string - -const ( - AccessEntryFlagNone AccessEntryFlag = "NONE" - AccessEntryFlagOrphaned AccessEntryFlag = "ORPHANED" - AccessEntryFlagInactive AccessEntryFlag = "INACTIVE" - AccessEntryFlagExcessive AccessEntryFlag = "EXCESSIVE" - AccessEntryFlagRoleMismatch AccessEntryFlag = "ROLE_MISMATCH" - AccessEntryFlagNew AccessEntryFlag = "NEW" - AccessEntryFlagDormant AccessEntryFlag = "DORMANT" - AccessEntryFlagTerminatedUser AccessEntryFlag = "TERMINATED_USER" - AccessEntryFlagContractorExpired AccessEntryFlag = "CONTRACTOR_EXPIRED" - AccessEntryFlagSoDConflict AccessEntryFlag = "SOD_CONFLICT" - AccessEntryFlagPrivilegedAccess AccessEntryFlag = "PRIVILEGED_ACCESS" - AccessEntryFlagRoleCreep AccessEntryFlag = "ROLE_CREEP" - AccessEntryFlagNoBusinessJustification AccessEntryFlag = "NO_BUSINESS_JUSTIFICATION" - AccessEntryFlagOutOfDepartment AccessEntryFlag = "OUT_OF_DEPARTMENT" - AccessEntryFlagSharedAccount AccessEntryFlag = "SHARED_ACCOUNT" -) - -var ( - _ fmt.Stringer = AccessEntryFlag("") - _ encoding.TextMarshaler = AccessEntryFlag("") - _ encoding.TextUnmarshaler = (*AccessEntryFlag)(nil) -) - -func AccessEntryFlags() []AccessEntryFlag { - return []AccessEntryFlag{ - AccessEntryFlagNone, - AccessEntryFlagOrphaned, - AccessEntryFlagInactive, - AccessEntryFlagExcessive, - AccessEntryFlagRoleMismatch, - AccessEntryFlagNew, - AccessEntryFlagDormant, - AccessEntryFlagTerminatedUser, - AccessEntryFlagContractorExpired, - AccessEntryFlagSoDConflict, - AccessEntryFlagPrivilegedAccess, - AccessEntryFlagRoleCreep, - AccessEntryFlagNoBusinessJustification, - AccessEntryFlagOutOfDepartment, - AccessEntryFlagSharedAccount, - } -} - -func (v AccessEntryFlag) IsValid() bool { - switch v { - case - AccessEntryFlagNone, - AccessEntryFlagOrphaned, - AccessEntryFlagInactive, - AccessEntryFlagExcessive, - AccessEntryFlagRoleMismatch, - AccessEntryFlagNew, - AccessEntryFlagDormant, - AccessEntryFlagTerminatedUser, - AccessEntryFlagContractorExpired, - AccessEntryFlagSoDConflict, - AccessEntryFlagPrivilegedAccess, - AccessEntryFlagRoleCreep, - AccessEntryFlagNoBusinessJustification, - AccessEntryFlagOutOfDepartment, - AccessEntryFlagSharedAccount: - return true - } - - return false -} - -func (v AccessEntryFlag) String() string { - return string(v) -} - -func (v AccessEntryFlag) MarshalText() ([]byte, error) { - return []byte(v.String()), nil -} - -func (v *AccessEntryFlag) UnmarshalText(text []byte) error { - val := AccessEntryFlag(text) - if !val.IsValid() { - return fmt.Errorf("invalid AccessEntryFlag value: %q", string(text)) - } - - *v = val - - return nil -} diff --git a/pkg/coredata/access_entry_incremental_tag.go b/pkg/coredata/access_entry_incremental_tag.go deleted file mode 100644 index 52949c69b..000000000 --- a/pkg/coredata/access_entry_incremental_tag.go +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) 2026 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 ( - "encoding" - "fmt" -) - -type AccessEntryIncrementalTag string - -const ( - AccessEntryIncrementalTagNew AccessEntryIncrementalTag = "NEW" - AccessEntryIncrementalTagRemoved AccessEntryIncrementalTag = "REMOVED" - AccessEntryIncrementalTagUnchanged AccessEntryIncrementalTag = "UNCHANGED" -) - -var ( - _ fmt.Stringer = AccessEntryIncrementalTag("") - _ encoding.TextMarshaler = AccessEntryIncrementalTag("") - _ encoding.TextUnmarshaler = (*AccessEntryIncrementalTag)(nil) -) - -func AccessEntryIncrementalTags() []AccessEntryIncrementalTag { - return []AccessEntryIncrementalTag{ - AccessEntryIncrementalTagNew, - AccessEntryIncrementalTagRemoved, - AccessEntryIncrementalTagUnchanged, - } -} - -func (v AccessEntryIncrementalTag) IsValid() bool { - switch v { - case - AccessEntryIncrementalTagNew, - AccessEntryIncrementalTagRemoved, - AccessEntryIncrementalTagUnchanged: - return true - } - - return false -} - -func (v AccessEntryIncrementalTag) String() string { - return string(v) -} - -func (v AccessEntryIncrementalTag) MarshalText() ([]byte, error) { - return []byte(v.String()), nil -} - -func (v *AccessEntryIncrementalTag) UnmarshalText(text []byte) error { - val := AccessEntryIncrementalTag(text) - if !val.IsValid() { - return fmt.Errorf("invalid AccessEntryIncrementalTag value: %q", string(text)) - } - - *v = val - - return nil -} diff --git a/pkg/coredata/access_entry_upsert_test.go b/pkg/coredata/access_entry_upsert_test.go deleted file mode 100644 index 5d7ed2c77..000000000 --- a/pkg/coredata/access_entry_upsert_test.go +++ /dev/null @@ -1,486 +0,0 @@ -// Copyright (c) 2026 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_test - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.gearno.de/kit/pg" - "go.probo.inc/probo/internal/test" - "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/gid" -) - -// accessEntryFixture bootstraps the parent rows (organization, campaign, -// source) that the access_entries FKs require. -type accessEntryFixture struct { - scope *coredata.Scope - organizationID gid.GID - campaignID gid.GID - sourceID gid.GID - accountKey string -} - -func seedAccessEntryFixture(t *testing.T, ctx context.Context, client *pg.Client) accessEntryFixture { - t.Helper() - - tenantID := gid.NewTenantID() - scope := coredata.NewScope(tenantID) - organizationID := gid.New(tenantID, coredata.OrganizationEntityType) - campaignID := gid.New(tenantID, coredata.AccessReviewCampaignEntityType) - sourceID := gid.New(tenantID, coredata.AccessSourceEntityType) - accountKey := "upsert-freeze-test@example.com" - now := time.Now().UTC() - - require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { - org := &coredata.Organization{ - ID: organizationID, - TenantID: tenantID, - Name: "Upsert Freeze Test Org", - CreatedAt: now, - UpdatedAt: now, - } - if err := org.Insert(ctx, tx); err != nil { - return err - } - - source := &coredata.AccessSource{ - ID: sourceID, - OrganizationID: organizationID, - Name: "Upsert Freeze Test Source", - Category: coredata.AccessSourceCategorySaaS, - CreatedAt: now, - UpdatedAt: now, - } - if err := source.Insert(ctx, tx, scope); err != nil { - return err - } - - campaign := &coredata.AccessReviewCampaign{ - ID: campaignID, - OrganizationID: organizationID, - Name: "Upsert Freeze Test Campaign", - Status: coredata.AccessReviewCampaignStatusDraft, - CreatedAt: now, - UpdatedAt: now, - } - if err := campaign.Insert(ctx, tx, scope); err != nil { - return err - } - - return nil - })) - - t.Cleanup(func() { - _ = client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error { - // Delete access_entries first (no ON DELETE CASCADE for the org side), - // then parents. - if _, err := tx.Exec(ctx, `DELETE FROM access_entries WHERE access_review_campaign_id = $1`, campaignID); err != nil { - return err - } - - if _, err := tx.Exec(ctx, `DELETE FROM access_review_campaigns WHERE id = $1`, campaignID); err != nil { - return err - } - - if _, err := tx.Exec(ctx, `DELETE FROM access_sources WHERE id = $1`, sourceID); err != nil { - return err - } - - if _, err := tx.Exec(ctx, `DELETE FROM organizations WHERE id = $1`, organizationID); err != nil { - return err - } - - return nil - }) - }) - - return accessEntryFixture{ - scope: scope, - organizationID: organizationID, - campaignID: campaignID, - sourceID: sourceID, - accountKey: accountKey, - } -} - -func TestAccessEntry_Upsert_FreezesDecidedFields(t *testing.T) { - t.Parallel() - - client := test.PGClient(t) - ctx := context.Background() - fx := seedAccessEntryFixture(t, ctx, client) - - tenantID := fx.scope.GetTenantID() - originalFlagReasons := []string{"original-flag-reason"} - originalFlags := []coredata.AccessEntryFlag{coredata.AccessEntryFlagNew} - originalEmail := "old@example.com" - originalFullName := "Old Name" - originalRole := "viewer" - - t0 := time.Now().UTC().Truncate(time.Microsecond) - - // Step 1: Initial Upsert with PENDING decision. - entryID := gid.New(tenantID, coredata.AccessEntryEntityType) - initial := &coredata.AccessEntry{ - ID: entryID, - OrganizationID: fx.organizationID, - AccessReviewCampaignID: fx.campaignID, - AccessSourceID: fx.sourceID, - Email: originalEmail, - FullName: originalFullName, - Role: originalRole, - JobTitle: "", - IsAdmin: false, - MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, - ExternalID: "ext-1", - AccountKey: fx.accountKey, - IncrementalTag: coredata.AccessEntryIncrementalTagNew, - Flags: originalFlags, - FlagReasons: originalFlagReasons, - Decision: coredata.AccessEntryDecisionPending, - DecisionNote: nil, - DecidedBy: nil, - DecidedAt: nil, - CreatedAt: t0, - UpdatedAt: t0, - } - - require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { - return initial.Upsert(ctx, tx, fx.scope) - })) - - // Step 2: Record a decision via Update — APPROVED with decided_by / decided_at. - decisionTime := t0.Add(1 * time.Hour) - decidedBy := gid.New(tenantID, coredata.OrganizationEntityType) // opaque ID suffices: decided_by has no FK. - decisionNote := "looks good" - - decided := &coredata.AccessEntry{ - ID: entryID, - Flags: originalFlags, - FlagReasons: originalFlagReasons, - Decision: coredata.AccessEntryDecisionApproved, - DecisionNote: &decisionNote, - DecidedBy: &decidedBy, - DecidedAt: &decisionTime, - UpdatedAt: decisionTime, - } - - require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { - return decided.Update(ctx, tx, fx.scope) - })) - - // Step 3: Second Upsert with the same unique key but new flags, new - // flag reasons, PENDING decision, nil note/decidedBy/decidedAt, and - // refreshed top-level fields (email, full_name, role). - t2 := decisionTime.Add(1 * time.Hour) - secondEmail := "new@example.com" - secondFullName := "New Name" - secondRole := "admin" - refresh := &coredata.AccessEntry{ - ID: gid.New(tenantID, coredata.AccessEntryEntityType), // ignored by ON CONFLICT - OrganizationID: fx.organizationID, - AccessReviewCampaignID: fx.campaignID, - AccessSourceID: fx.sourceID, - Email: secondEmail, - FullName: secondFullName, - Role: secondRole, - JobTitle: "", - IsAdmin: true, - MFAStatus: coredata.MFAStatusEnabled, - AuthMethod: coredata.AccessEntryAuthMethodSSO, - AccountType: coredata.AccessEntryAccountTypeUser, - ExternalID: "ext-1", - AccountKey: fx.accountKey, - IncrementalTag: coredata.AccessEntryIncrementalTagUnchanged, - Flags: []coredata.AccessEntryFlag{coredata.AccessEntryFlagInactive}, - FlagReasons: []string{"refreshed-flag-reason"}, - Decision: coredata.AccessEntryDecisionPending, - DecisionNote: nil, - DecidedBy: nil, - DecidedAt: nil, - CreatedAt: t2, - UpdatedAt: t2, - } - - require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { - return refresh.Upsert(ctx, tx, fx.scope) - })) - - // Step 4: Load and assert the freeze semantics. - loaded := &coredata.AccessEntry{} - - require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { - return loaded.LoadByID(ctx, conn, fx.scope, entryID) - })) - - // Decision fields are FROZEN at APPROVED / decided_by / decided_at / - // decision_note from the Update call. - assert.Equal(t, coredata.AccessEntryDecisionApproved, loaded.Decision, "decision must be frozen once locked") - require.NotNil(t, loaded.DecidedBy, "decided_by must be preserved") - assert.Equal(t, decidedBy, *loaded.DecidedBy) - require.NotNil(t, loaded.DecidedAt, "decided_at must be preserved") - assert.WithinDuration(t, decisionTime, *loaded.DecidedAt, time.Second) - require.NotNil(t, loaded.DecisionNote, "decision_note must be preserved") - assert.Equal(t, decisionNote, *loaded.DecisionNote) - - // Flags / flag_reasons are FROZEN (the new guard from Task 1): once a - // reviewer locks a decision, the evidence that drove that decision must - // not be silently replaced by a subsequent poll. - assert.Equal(t, originalFlags, loaded.Flags, "flags must be frozen once decision is locked") - assert.Equal(t, originalFlagReasons, loaded.FlagReasons, "flag_reasons must be frozen once decision is locked") - - // Columns that ARE refreshed on every poll. - assert.Equal(t, secondEmail, loaded.Email) - assert.Equal(t, secondFullName, loaded.FullName) - assert.Equal(t, secondRole, loaded.Role) - assert.True(t, loaded.IsAdmin) - assert.Equal(t, coredata.MFAStatusEnabled, loaded.MFAStatus) - assert.Equal(t, coredata.AccessEntryAuthMethodSSO, loaded.AuthMethod) - assert.WithinDuration(t, t2, loaded.UpdatedAt, time.Second) -} - -// TestAccessEntry_Upsert_RefreshesSourceTrackingFields pins the contract of -// the ON CONFLICT DO UPDATE SET clause: across repeated polls of the same -// (campaign, source, account_key), the columns that track live source state -// (email, full_name, role, is_admin, MFA, auth_method, last_login, etc.) -// move forward to the latest values, while the verdict-related columns -// (flags, flag_reasons, decision, decision_note, decided_by, decided_at) are -// never written by a re-poll -- those can only change through Update. -func TestAccessEntry_Upsert_RefreshesSourceTrackingFields(t *testing.T) { - t.Parallel() - - client := test.PGClient(t) - ctx := context.Background() - fx := seedAccessEntryFixture(t, ctx, client) - - tenantID := fx.scope.GetTenantID() - t0 := time.Now().UTC().Truncate(time.Microsecond) - - entryID := gid.New(tenantID, coredata.AccessEntryEntityType) - first := &coredata.AccessEntry{ - ID: entryID, - OrganizationID: fx.organizationID, - AccessReviewCampaignID: fx.campaignID, - AccessSourceID: fx.sourceID, - Email: "old@example.com", - FullName: "Old Name", - Role: "viewer", - MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, - ExternalID: "ext-2", - AccountKey: fx.accountKey, - IncrementalTag: coredata.AccessEntryIncrementalTagNew, - Flags: []coredata.AccessEntryFlag{}, - FlagReasons: []string{}, - Decision: coredata.AccessEntryDecisionPending, - CreatedAt: t0, - UpdatedAt: t0, - } - - require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { - return first.Upsert(ctx, tx, fx.scope) - })) - - t1 := t0.Add(1 * time.Hour) - second := &coredata.AccessEntry{ - ID: gid.New(tenantID, coredata.AccessEntryEntityType), - OrganizationID: fx.organizationID, - AccessReviewCampaignID: fx.campaignID, - AccessSourceID: fx.sourceID, - Email: "new@example.com", - FullName: "New Name", - Role: "admin", - MFAStatus: coredata.MFAStatusEnabled, - AuthMethod: coredata.AccessEntryAuthMethodSSO, - AccountType: coredata.AccessEntryAccountTypeUser, - ExternalID: "ext-2", - AccountKey: fx.accountKey, - IncrementalTag: coredata.AccessEntryIncrementalTagUnchanged, - Flags: []coredata.AccessEntryFlag{}, - FlagReasons: []string{}, - Decision: coredata.AccessEntryDecisionPending, - CreatedAt: t1, - UpdatedAt: t1, - } - - require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { - return second.Upsert(ctx, tx, fx.scope) - })) - - loaded := &coredata.AccessEntry{} - - require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { - return loaded.LoadByID(ctx, conn, fx.scope, entryID) - })) - - // Source-tracking columns advanced to the second poll's values. - assert.Equal(t, "new@example.com", loaded.Email) - assert.Equal(t, "New Name", loaded.FullName) - assert.Equal(t, "admin", loaded.Role) - assert.Equal(t, coredata.MFAStatusEnabled, loaded.MFAStatus) - assert.Equal(t, coredata.AccessEntryAuthMethodSSO, loaded.AuthMethod) - - // Verdict-related columns stayed at whatever the first Upsert set (empty / - // PENDING); the second Upsert did not touch them. - assert.Equal(t, coredata.AccessEntryDecisionPending, loaded.Decision) - assert.Equal(t, []coredata.AccessEntryFlag{}, loaded.Flags) - assert.Equal(t, []string{}, loaded.FlagReasons) - assert.Nil(t, loaded.DecisionNote) - assert.Nil(t, loaded.DecidedBy) - assert.Nil(t, loaded.DecidedAt) -} - -func TestAccessEntry_Upsert_RefreshesActiveStatus(t *testing.T) { - t.Parallel() - - client := test.PGClient(t) - ctx := context.Background() - fx := seedAccessEntryFixture(t, ctx, client) - - tenantID := fx.scope.GetTenantID() - t0 := time.Now().UTC().Truncate(time.Microsecond) - activeTrue := true - activeFalse := false - - entryID := gid.New(tenantID, coredata.AccessEntryEntityType) - first := &coredata.AccessEntry{ - ID: entryID, - OrganizationID: fx.organizationID, - AccessReviewCampaignID: fx.campaignID, - AccessSourceID: fx.sourceID, - Email: "user@example.com", - FullName: "User", - Role: "member", - MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, - Active: &activeTrue, - ExternalID: "ext-active", - AccountKey: fx.accountKey, - IncrementalTag: coredata.AccessEntryIncrementalTagNew, - Flags: []coredata.AccessEntryFlag{}, - FlagReasons: []string{}, - Decision: coredata.AccessEntryDecisionPending, - CreatedAt: t0, - UpdatedAt: t0, - } - - require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { - return first.Upsert(ctx, tx, fx.scope) - })) - - t1 := t0.Add(1 * time.Hour) - second := &coredata.AccessEntry{ - ID: gid.New(tenantID, coredata.AccessEntryEntityType), - OrganizationID: fx.organizationID, - AccessReviewCampaignID: fx.campaignID, - AccessSourceID: fx.sourceID, - Email: "user@example.com", - FullName: "User", - Role: "member", - MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, - Active: &activeFalse, - ExternalID: "ext-active", - AccountKey: fx.accountKey, - IncrementalTag: coredata.AccessEntryIncrementalTagUnchanged, - Flags: []coredata.AccessEntryFlag{}, - FlagReasons: []string{}, - Decision: coredata.AccessEntryDecisionPending, - CreatedAt: t1, - UpdatedAt: t1, - } - - require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { - return second.Upsert(ctx, tx, fx.scope) - })) - - loaded := &coredata.AccessEntry{} - - require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { - return loaded.LoadByID(ctx, conn, fx.scope, entryID) - })) - - require.NotNil(t, loaded.Active) - assert.False(t, *loaded.Active) -} - -// TestAccessEntry_Upsert_InsertsActiveAccount covers the shape FetchSource -// builds for an active account: a PENDING decision and explicit empty -// flags / flag_reasons slices. The access_entries.flags and flag_reasons -// columns are declared NOT NULL, so the caller (FetchSource) is responsible -// for passing non-nil slices. -func TestAccessEntry_Upsert_InsertsActiveAccount(t *testing.T) { - t.Parallel() - - client := test.PGClient(t) - ctx := context.Background() - fx := seedAccessEntryFixture(t, ctx, client) - - tenantID := fx.scope.GetTenantID() - t0 := time.Now().UTC().Truncate(time.Microsecond) - - activeTrue := true - entryID := gid.New(tenantID, coredata.AccessEntryEntityType) - entry := &coredata.AccessEntry{ - ID: entryID, - OrganizationID: fx.organizationID, - AccessReviewCampaignID: fx.campaignID, - AccessSourceID: fx.sourceID, - Email: "active@example.com", - FullName: "Active User", - Role: "member", - MFAStatus: coredata.MFAStatusUnknown, - AuthMethod: coredata.AccessEntryAuthMethodUnknown, - AccountType: coredata.AccessEntryAccountTypeUser, - Active: &activeTrue, - ExternalID: "ext-active", - AccountKey: fx.accountKey, - IncrementalTag: coredata.AccessEntryIncrementalTagNew, - Flags: []coredata.AccessEntryFlag{}, - FlagReasons: []string{}, - Decision: coredata.AccessEntryDecisionPending, - CreatedAt: t0, - UpdatedAt: t0, - } - - require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { - return entry.Upsert(ctx, tx, fx.scope) - })) - - loaded := &coredata.AccessEntry{} - - require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { - return loaded.LoadByID(ctx, conn, fx.scope, entryID) - })) - - require.NotNil(t, loaded.Active) - assert.True(t, *loaded.Active) - assert.Equal(t, coredata.AccessEntryDecisionPending, loaded.Decision) - assert.Equal(t, []coredata.AccessEntryFlag{}, loaded.Flags) - assert.Equal(t, []string{}, loaded.FlagReasons) - assert.Nil(t, loaded.DecisionNote) - assert.Nil(t, loaded.DecidedBy) - assert.Nil(t, loaded.DecidedAt) -} diff --git a/pkg/coredata/access_review_campaign.go b/pkg/coredata/access_review_campaign.go index 1280eeaad..dfba62c19 100644 --- a/pkg/coredata/access_review_campaign.go +++ b/pkg/coredata/access_review_campaign.go @@ -54,6 +54,34 @@ func (c AccessReviewCampaign) CursorKey(orderBy AccessReviewCampaignOrderField) panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } +func (c *AccessReviewCampaign) LockForUpdate( + ctx context.Context, + conn pg.Tx, + scope Scoper, +) error { + q := ` +SELECT id +FROM access_review_campaigns +WHERE %s + AND id = @id +FOR UPDATE +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{"id": c.ID} + maps.Copy(args, scope.SQLArguments()) + + var id gid.GID + if err := conn.QueryRow(ctx, q, args).Scan(&id); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot lock campaign: %w", err) + } + + return nil +} + func (c *AccessReviewCampaign) AuthorizationAttributes( ctx context.Context, conn pg.Querier, diff --git a/pkg/coredata/access_review_campaign_scope_system.go b/pkg/coredata/access_review_campaign_scope_system.go deleted file mode 100644 index 9a56c60be..000000000 --- a/pkg/coredata/access_review_campaign_scope_system.go +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -// PERFORMANCE OF THIS SOFTWARE. - -package coredata - -import ( - "context" - "errors" - "fmt" - "maps" - "time" - - "github.com/jackc/pgx/v5" - "go.gearno.de/kit/pg" - "go.probo.inc/probo/pkg/gid" -) - -type AccessReviewCampaignScopeSystem struct { - AccessReviewCampaignID gid.GID `db:"access_review_campaign_id"` - AccessSourceID gid.GID `db:"access_source_id"` -} - -func (ss AccessReviewCampaignScopeSystem) Insert( - ctx context.Context, - conn pg.Tx, - scope Scoper, -) error { - q := ` -INSERT INTO access_review_campaign_scope_systems (access_review_campaign_id, access_source_id, tenant_id) -VALUES (@access_review_campaign_id, @access_source_id, @tenant_id) -` - args := pgx.StrictNamedArgs{ - "access_review_campaign_id": ss.AccessReviewCampaignID, - "access_source_id": ss.AccessSourceID, - "tenant_id": scope.GetTenantID(), - } - - _, err := conn.Exec(ctx, q, args) - if err != nil { - return fmt.Errorf("cannot insert campaign scope system: %w", err) - } - - return nil -} - -func (ss AccessReviewCampaignScopeSystem) Upsert( - ctx context.Context, - conn pg.Querier, - scope Scoper, -) error { - q := ` -INSERT INTO access_review_campaign_scope_systems (access_review_campaign_id, access_source_id, tenant_id) -VALUES (@access_review_campaign_id, @access_source_id, @tenant_id) -ON CONFLICT (access_review_campaign_id, access_source_id) DO NOTHING -` - args := pgx.StrictNamedArgs{ - "access_review_campaign_id": ss.AccessReviewCampaignID, - "access_source_id": ss.AccessSourceID, - "tenant_id": scope.GetTenantID(), - } - - _, err := conn.Exec(ctx, q, args) - if err != nil { - return fmt.Errorf("cannot upsert campaign scope system: %w", err) - } - - return nil -} - -func (ss AccessReviewCampaignScopeSystem) Delete( - ctx context.Context, - conn pg.Tx, - scope Scoper, -) error { - q := ` -DELETE FROM access_review_campaign_scope_systems -WHERE - %s - AND access_review_campaign_id = @access_review_campaign_id - AND access_source_id = @access_source_id -` - q = fmt.Sprintf(q, scope.SQLFragment()) - - args := pgx.StrictNamedArgs{ - "access_review_campaign_id": ss.AccessReviewCampaignID, - "access_source_id": ss.AccessSourceID, - } - maps.Copy(args, scope.SQLArguments()) - - _, err := conn.Exec(ctx, q, args) - if err != nil { - return fmt.Errorf("cannot delete campaign scope system: %w", err) - } - - return nil -} - -func (c *AccessReviewCampaign) LockForUpdate( - ctx context.Context, - conn pg.Tx, - scope Scoper, -) error { - q := ` -SELECT id -FROM access_review_campaigns -WHERE %s - AND id = @id -FOR UPDATE -` - q = fmt.Sprintf(q, scope.SQLFragment()) - args := pgx.StrictNamedArgs{"id": c.ID} - maps.Copy(args, scope.SQLArguments()) - - var id gid.GID - if err := conn.QueryRow(ctx, q, args).Scan(&id); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrResourceNotFound - } - - return fmt.Errorf("cannot lock campaign: %w", err) - } - - return nil -} - -func (f *AccessReviewCampaignSourceFetch) UpsertQueued( - ctx context.Context, - conn pg.Querier, - scope Scoper, - now time.Time, -) error { - q := ` -INSERT INTO access_review_campaign_source_fetches ( - tenant_id, - access_review_campaign_id, - access_source_id, - status, - fetched_accounts_count, - attempt_count, - last_error, - started_at, - completed_at, - created_at, - updated_at -) VALUES ( - @tenant_id, @access_review_campaign_id, @access_source_id, - 'QUEUED', 0, 0, NULL, NULL, NULL, @now, @now -) -ON CONFLICT (access_review_campaign_id, access_source_id) DO UPDATE SET - status = 'QUEUED', - fetched_accounts_count = 0, - attempt_count = 0, - last_error = NULL, - started_at = NULL, - completed_at = NULL, - updated_at = EXCLUDED.updated_at -` - args := pgx.StrictNamedArgs{ - "tenant_id": scope.GetTenantID(), - "access_review_campaign_id": f.AccessReviewCampaignID, - "access_source_id": f.AccessSourceID, - "now": now, - } - - _, err := conn.Exec(ctx, q, args) - if err != nil { - return fmt.Errorf("cannot upsert queued source fetch: %w", err) - } - - return nil -} - -// RecoverStale is intentionally cross-tenant: the background worker recovers -// all stale fetches regardless of tenant. -func (fs *AccessReviewCampaignSourceFetches) RecoverStale( - ctx context.Context, - conn pg.Querier, - staleThreshold time.Time, - now time.Time, -) (int64, error) { - q := ` -UPDATE access_review_campaign_source_fetches -SET - status = 'QUEUED', - last_error = 'recovered from stale FETCHING state', - started_at = NULL, - completed_at = NULL, - updated_at = @now -WHERE - status = 'FETCHING' - AND updated_at < @stale_threshold -` - args := pgx.StrictNamedArgs{ - "now": now, - "stale_threshold": staleThreshold, - } - - result, err := conn.Exec(ctx, q, args) - if err != nil { - return 0, fmt.Errorf("cannot recover stale source fetches: %w", err) - } - - return result.RowsAffected(), nil -} diff --git a/pkg/coredata/access_review_campaign_source.go b/pkg/coredata/access_review_campaign_source.go new file mode 100644 index 000000000..d9c377ec0 --- /dev/null +++ b/pkg/coredata/access_review_campaign_source.go @@ -0,0 +1,224 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" +) + +type ( + // AccessReviewCampaignSource is the per-campaign snapshot of an access + // source. It captures the source identity (name, category, connector) at + // the time the source was scoped into the campaign so that the review's + // data survives even if the live access source is later deleted. Access + // entries and fetch attempts reference this snapshot, not the live source. + AccessReviewCampaignSource struct { + ID gid.GID `db:"id"` + TenantID gid.TenantID `db:"tenant_id"` + AccessReviewCampaignID gid.GID `db:"access_review_campaign_id"` + AccessReviewSourceID *gid.GID `db:"access_review_source_id"` + Name string `db:"name"` + Category AccessReviewSourceCategory `db:"category"` + ConnectorID *gid.GID `db:"connector_id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + AccessReviewCampaignSources []*AccessReviewCampaignSource +) + +// Upsert inserts the snapshot or refreshes its denormalized identity from the +// live source. The generated ID is preserved across upserts because it is not +// part of the conflict target, so entries that already reference the snapshot +// keep pointing at the same row. +func (s *AccessReviewCampaignSource) Upsert( + ctx context.Context, + conn pg.Tx, + scope Scoper, +) error { + q := ` +INSERT INTO access_review_campaign_sources ( + id, + tenant_id, + access_review_campaign_id, + access_review_source_id, + name, + category, + connector_id, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @access_review_campaign_id, + @access_review_source_id, + @name, + @category, + @connector_id, + @created_at, + @updated_at +) +ON CONFLICT (access_review_campaign_id, access_review_source_id) DO UPDATE SET + name = EXCLUDED.name, + category = EXCLUDED.category, + connector_id = EXCLUDED.connector_id, + updated_at = EXCLUDED.updated_at +RETURNING id +` + args := pgx.StrictNamedArgs{ + "id": s.ID, + "tenant_id": scope.GetTenantID(), + "access_review_campaign_id": s.AccessReviewCampaignID, + "access_review_source_id": s.AccessReviewSourceID, + "name": s.Name, + "category": s.Category, + "connector_id": s.ConnectorID, + "created_at": s.CreatedAt, + "updated_at": s.UpdatedAt, + } + + if err := conn.QueryRow(ctx, q, args).Scan(&s.ID); err != nil { + return fmt.Errorf("cannot upsert campaign source: %w", err) + } + + return nil +} + +func (s *AccessReviewCampaignSource) LoadByID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + id gid.GID, +) error { + q := ` +SELECT + id, + tenant_id, + access_review_campaign_id, + access_review_source_id, + name, + category, + connector_id, + created_at, + updated_at +FROM access_review_campaign_sources +WHERE + %s + AND id = @id +LIMIT 1 +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"id": id} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query campaign source: %w", err) + } + + result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessReviewCampaignSource]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect campaign source: %w", err) + } + + *s = result + + return nil +} + +func (s *AccessReviewCampaignSource) DeleteByCampaignIDAndAccessReviewSourceID( + ctx context.Context, + conn pg.Tx, + scope Scoper, + campaignID gid.GID, + accessSourceID gid.GID, +) error { + q := ` +DELETE FROM access_review_campaign_sources +WHERE + %s + AND access_review_campaign_id = @access_review_campaign_id + AND access_review_source_id = @access_review_source_id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "access_review_campaign_id": campaignID, + "access_review_source_id": accessSourceID, + } + maps.Copy(args, scope.SQLArguments()) + + if _, err := conn.Exec(ctx, q, args); err != nil { + return fmt.Errorf("cannot delete campaign source: %w", err) + } + + return nil +} + +func (sources *AccessReviewCampaignSources) LoadByCampaignID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + campaignID gid.GID, +) error { + q := ` +SELECT + id, + tenant_id, + access_review_campaign_id, + access_review_source_id, + name, + category, + connector_id, + created_at, + updated_at +FROM access_review_campaign_sources +WHERE + %s + AND access_review_campaign_id = @access_review_campaign_id +ORDER BY name ASC +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"access_review_campaign_id": campaignID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query campaign sources: %w", err) + } + + result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewCampaignSource]) + if err != nil { + return fmt.Errorf("cannot collect campaign sources: %w", err) + } + + *sources = result + + return nil +} diff --git a/pkg/coredata/access_review_campaign_source_fetch.go b/pkg/coredata/access_review_campaign_source_fetch.go deleted file mode 100644 index e179668b3..000000000 --- a/pkg/coredata/access_review_campaign_source_fetch.go +++ /dev/null @@ -1,305 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -// PERFORMANCE OF THIS SOFTWARE. - -package coredata - -import ( - "context" - "errors" - "fmt" - "maps" - "time" - - "github.com/jackc/pgx/v5" - "go.gearno.de/kit/pg" - "go.probo.inc/probo/pkg/gid" -) - -type ( - // AccessReviewCampaignSourceFetch tracks per-source fetch lifecycle. - // TenantID is retained on the struct because the background worker claims - // rows cross-tenant via LoadNextQueuedForUpdateSkipLocked and needs the - // tenant to construct a Scope for subsequent operations. - AccessReviewCampaignSourceFetch struct { - TenantID gid.TenantID `db:"tenant_id"` - AccessReviewCampaignID gid.GID `db:"access_review_campaign_id"` - AccessSourceID gid.GID `db:"access_source_id"` - Status AccessReviewCampaignSourceFetchStatus `db:"status"` - FetchedAccountsCount int `db:"fetched_accounts_count"` - AttemptCount int `db:"attempt_count"` - LastError *string `db:"last_error"` - StartedAt *time.Time `db:"started_at"` - CompletedAt *time.Time `db:"completed_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - } - - AccessReviewCampaignSourceFetches []*AccessReviewCampaignSourceFetch -) - -var ( - ErrNoAccessReviewCampaignSourceFetchAvailable = errors.New("no access review campaign source fetch available") -) - -func (f *AccessReviewCampaignSourceFetch) Insert( - ctx context.Context, - conn pg.Tx, - scope Scoper, -) error { - q := ` -INSERT INTO access_review_campaign_source_fetches ( - tenant_id, - access_review_campaign_id, - access_source_id, - status, - fetched_accounts_count, - attempt_count, - last_error, - started_at, - completed_at, - created_at, - updated_at -) VALUES ( - @tenant_id, - @access_review_campaign_id, - @access_source_id, - @status, - @fetched_accounts_count, - @attempt_count, - @last_error, - @started_at, - @completed_at, - @created_at, - @updated_at -) -` - args := pgx.StrictNamedArgs{ - "tenant_id": scope.GetTenantID(), - "access_review_campaign_id": f.AccessReviewCampaignID, - "access_source_id": f.AccessSourceID, - "status": f.Status, - "fetched_accounts_count": f.FetchedAccountsCount, - "attempt_count": f.AttemptCount, - "last_error": f.LastError, - "started_at": f.StartedAt, - "completed_at": f.CompletedAt, - "created_at": f.CreatedAt, - "updated_at": f.UpdatedAt, - } - - _, err := conn.Exec(ctx, q, args) - if err != nil { - return fmt.Errorf("cannot insert campaign source fetch: %w", err) - } - - return nil -} - -func (f *AccessReviewCampaignSourceFetch) Update( - ctx context.Context, - conn pg.Tx, - scope Scoper, -) error { - q := ` -UPDATE access_review_campaign_source_fetches -SET - status = @status, - fetched_accounts_count = @fetched_accounts_count, - attempt_count = @attempt_count, - last_error = @last_error, - started_at = @started_at, - completed_at = @completed_at, - updated_at = @updated_at -WHERE - %s - AND access_review_campaign_id = @access_review_campaign_id - AND access_source_id = @access_source_id -` - q = fmt.Sprintf(q, scope.SQLFragment()) - - args := pgx.StrictNamedArgs{ - "status": f.Status, - "fetched_accounts_count": f.FetchedAccountsCount, - "attempt_count": f.AttemptCount, - "last_error": f.LastError, - "started_at": f.StartedAt, - "completed_at": f.CompletedAt, - "updated_at": f.UpdatedAt, - "access_review_campaign_id": f.AccessReviewCampaignID, - "access_source_id": f.AccessSourceID, - } - maps.Copy(args, scope.SQLArguments()) - - result, err := conn.Exec(ctx, q, args) - if err != nil { - return fmt.Errorf("cannot update campaign source fetch: %w", err) - } - - if result.RowsAffected() == 0 { - return ErrResourceNotFound - } - - return nil -} - -func (f *AccessReviewCampaignSourceFetch) LoadByID( - ctx context.Context, - conn pg.Querier, - scope Scoper, - campaignID gid.GID, - sourceID gid.GID, -) error { - q := ` -SELECT - tenant_id, - access_review_campaign_id, - access_source_id, - status, - fetched_accounts_count, - attempt_count, - last_error, - started_at, - completed_at, - created_at, - updated_at -FROM access_review_campaign_source_fetches -WHERE - %s - AND access_review_campaign_id = @access_review_campaign_id - AND access_source_id = @access_source_id -LIMIT 1 -` - q = fmt.Sprintf(q, scope.SQLFragment()) - - args := pgx.StrictNamedArgs{ - "access_review_campaign_id": campaignID, - "access_source_id": sourceID, - } - maps.Copy(args, scope.SQLArguments()) - - rows, err := conn.Query(ctx, q, args) - if err != nil { - return fmt.Errorf("cannot query campaign source fetch: %w", err) - } - - result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessReviewCampaignSourceFetch]) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrResourceNotFound - } - - return fmt.Errorf("cannot collect campaign source fetch: %w", err) - } - - *f = result - - return nil -} - -func (fs *AccessReviewCampaignSourceFetches) LoadByCampaignID( - ctx context.Context, - conn pg.Querier, - scope Scoper, - campaignID gid.GID, -) error { - q := ` -SELECT - tenant_id, - access_review_campaign_id, - access_source_id, - status, - fetched_accounts_count, - attempt_count, - last_error, - started_at, - completed_at, - created_at, - updated_at -FROM access_review_campaign_source_fetches -WHERE - %s - AND access_review_campaign_id = @access_review_campaign_id -ORDER BY created_at ASC -` - q = fmt.Sprintf(q, scope.SQLFragment()) - - args := pgx.StrictNamedArgs{ - "access_review_campaign_id": campaignID, - } - maps.Copy(args, scope.SQLArguments()) - - rows, err := conn.Query(ctx, q, args) - if err != nil { - return fmt.Errorf("cannot query campaign source fetches: %w", err) - } - - result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewCampaignSourceFetch]) - if err != nil { - return fmt.Errorf("cannot collect campaign source fetches: %w", err) - } - - *fs = result - - return nil -} - -// LoadNextQueuedForUpdateSkipLocked is intentionally cross-tenant: the -// background worker claims the next available fetch regardless of tenant. -// The caller extracts TenantID from the returned struct to construct a -// Scope for subsequent operations. -func (f *AccessReviewCampaignSourceFetch) LoadNextQueuedForUpdateSkipLocked( - ctx context.Context, - conn pg.Tx, -) error { - q := ` -SELECT - tenant_id, - access_review_campaign_id, - access_source_id, - status, - fetched_accounts_count, - attempt_count, - last_error, - started_at, - completed_at, - created_at, - updated_at -FROM access_review_campaign_source_fetches -WHERE status = @status -ORDER BY created_at ASC -LIMIT 1 -FOR UPDATE SKIP LOCKED -` - args := pgx.StrictNamedArgs{ - "status": AccessReviewCampaignSourceFetchStatusQueued, - } - - rows, err := conn.Query(ctx, q, args) - if err != nil { - return fmt.Errorf("cannot query next queued campaign source fetch: %w", err) - } - - result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessReviewCampaignSourceFetch]) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrNoAccessReviewCampaignSourceFetchAvailable - } - - return fmt.Errorf("cannot collect campaign source fetch: %w", err) - } - - *f = result - - return nil -} diff --git a/pkg/coredata/access_review_campaign_source_fetch_attempt.go b/pkg/coredata/access_review_campaign_source_fetch_attempt.go new file mode 100644 index 000000000..6ff3b25ff --- /dev/null +++ b/pkg/coredata/access_review_campaign_source_fetch_attempt.go @@ -0,0 +1,378 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" +) + +type ( + // AccessReviewCampaignSourceFetchAttempt is a single, append-only fetch run for a + // campaign source snapshot. Each retry produces a new row, so the error of + // every attempt is retained. The current state of a snapshot is the latest + // attempt (highest attempt_number). Terminal rows (SUCCESS / FAILED) are + // immutable; only the in-flight attempt is updated. + // + // TenantID is retained on the struct because the background worker claims + // rows cross-tenant via LoadNextQueuedForUpdateSkipLocked and needs the + // tenant to construct a Scope for subsequent operations. + AccessReviewCampaignSourceFetchAttempt struct { + ID gid.GID `db:"id"` + TenantID gid.TenantID `db:"tenant_id"` + AccessReviewCampaignSourceID gid.GID `db:"access_review_campaign_source_id"` + AttemptNumber int `db:"attempt_number"` + Status AccessReviewCampaignSourceFetchStatus `db:"status"` + FetchedAccountsCount int `db:"fetched_accounts_count"` + Error *string `db:"error"` + StartedAt *time.Time `db:"started_at"` + CompletedAt *time.Time `db:"completed_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + AccessReviewCampaignSourceFetchAttempts []*AccessReviewCampaignSourceFetchAttempt +) + +var ( + ErrNoAccessReviewCampaignSourceFetchAttemptAvailable = errors.New("no access review source fetch attempt available") +) + +// Insert appends a new attempt for the snapshot, assigning the next +// attempt_number atomically. The receiver's AttemptNumber is synced from the +// database. +func (a *AccessReviewCampaignSourceFetchAttempt) Insert( + ctx context.Context, + conn pg.Tx, + scope Scoper, +) error { + q := ` +INSERT INTO access_review_campaign_source_fetch_attempts ( + id, + tenant_id, + access_review_campaign_source_id, + attempt_number, + status, + fetched_accounts_count, + error, + started_at, + completed_at, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @access_review_campaign_source_id, + COALESCE(( + SELECT MAX(attempt_number) + FROM access_review_campaign_source_fetch_attempts + WHERE access_review_campaign_source_id = @access_review_campaign_source_id + ), 0) + 1, + @status, + @fetched_accounts_count, + @error, + @started_at, + @completed_at, + @created_at, + @updated_at +) +RETURNING attempt_number +` + args := pgx.StrictNamedArgs{ + "id": a.ID, + "tenant_id": scope.GetTenantID(), + "access_review_campaign_source_id": a.AccessReviewCampaignSourceID, + "status": a.Status, + "fetched_accounts_count": a.FetchedAccountsCount, + "error": a.Error, + "started_at": a.StartedAt, + "completed_at": a.CompletedAt, + "created_at": a.CreatedAt, + "updated_at": a.UpdatedAt, + } + + if err := conn.QueryRow(ctx, q, args).Scan(&a.AttemptNumber); err != nil { + return fmt.Errorf("cannot insert source fetch attempt: %w", err) + } + + return nil +} + +// Update writes the in-flight attempt's lifecycle fields. It must only be +// called on the attempt that the worker currently owns. +func (a *AccessReviewCampaignSourceFetchAttempt) Update( + ctx context.Context, + conn pg.Tx, + scope Scoper, +) error { + q := ` +UPDATE access_review_campaign_source_fetch_attempts +SET + status = @status, + fetched_accounts_count = @fetched_accounts_count, + error = @error, + started_at = @started_at, + completed_at = @completed_at, + updated_at = @updated_at +WHERE + %s + AND id = @id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "id": a.ID, + "status": a.Status, + "fetched_accounts_count": a.FetchedAccountsCount, + "error": a.Error, + "started_at": a.StartedAt, + "completed_at": a.CompletedAt, + "updated_at": a.UpdatedAt, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update source fetch attempt: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + return nil +} + +// LoadNextQueuedForUpdateSkipLocked is intentionally cross-tenant: the +// background worker claims the next available attempt regardless of tenant. +// The caller extracts TenantID from the returned struct to construct a Scope +// for subsequent operations. +func (a *AccessReviewCampaignSourceFetchAttempt) LoadNextQueuedForUpdateSkipLocked( + ctx context.Context, + conn pg.Tx, +) error { + q := ` +SELECT + id, + tenant_id, + access_review_campaign_source_id, + attempt_number, + status, + fetched_accounts_count, + error, + started_at, + completed_at, + created_at, + updated_at +FROM access_review_campaign_source_fetch_attempts +WHERE status = @status +ORDER BY created_at ASC +LIMIT 1 +FOR UPDATE SKIP LOCKED +` + args := pgx.StrictNamedArgs{ + "status": AccessReviewCampaignSourceFetchStatusQueued, + } + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query next queued fetch attempt: %w", err) + } + + result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessReviewCampaignSourceFetchAttempt]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNoAccessReviewCampaignSourceFetchAttemptAvailable + } + + return fmt.Errorf("cannot collect fetch attempt: %w", err) + } + + *a = result + + return nil +} + +// LoadLatestByCampaignID returns the most recent attempt for every snapshot in +// the campaign, keyed by snapshot ID. Snapshots without any attempt are absent. +func (attempts *AccessReviewCampaignSourceFetchAttempts) LoadLatestByCampaignID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + campaignID gid.GID, +) error { + q := ` +SELECT DISTINCT ON (access_review_campaign_source_id) + id, + tenant_id, + access_review_campaign_source_id, + attempt_number, + status, + fetched_accounts_count, + error, + started_at, + completed_at, + created_at, + updated_at +FROM access_review_campaign_source_fetch_attempts +WHERE + %s + AND access_review_campaign_source_id IN ( + SELECT id + FROM access_review_campaign_sources + WHERE access_review_campaign_id = @campaign_id + ) +ORDER BY access_review_campaign_source_id, attempt_number DESC +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"campaign_id": campaignID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query latest fetch attempts: %w", err) + } + + result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewCampaignSourceFetchAttempt]) + if err != nil { + return fmt.Errorf("cannot collect latest fetch attempts: %w", err) + } + + *attempts = result + + return nil +} + +// LoadByCampaignSourceID returns the full attempt history for a snapshot, +// newest first. +func (attempts *AccessReviewCampaignSourceFetchAttempts) LoadByCampaignSourceID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + campaignSourceID gid.GID, +) error { + q := ` +SELECT + id, + tenant_id, + access_review_campaign_source_id, + attempt_number, + status, + fetched_accounts_count, + error, + started_at, + completed_at, + created_at, + updated_at +FROM access_review_campaign_source_fetch_attempts +WHERE + %s + AND access_review_campaign_source_id = @access_review_campaign_source_id +ORDER BY attempt_number DESC +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"access_review_campaign_source_id": campaignSourceID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query fetch attempts: %w", err) + } + + result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewCampaignSourceFetchAttempt]) + if err != nil { + return fmt.Errorf("cannot collect fetch attempts: %w", err) + } + + *attempts = result + + return nil +} + +// RecoverStale fails attempts stuck in FETCHING past the threshold and queues a +// fresh retry attempt for each, preserving the stale attempt's history. It is +// intentionally cross-tenant. Returns the number of recovered attempts. +func (attempts *AccessReviewCampaignSourceFetchAttempts) RecoverStale( + ctx context.Context, + conn pg.Tx, + staleThreshold time.Time, + now time.Time, +) (int, error) { + q := ` +SELECT + id, + tenant_id, + access_review_campaign_source_id, + attempt_number, + status, + fetched_accounts_count, + error, + started_at, + completed_at, + created_at, + updated_at +FROM access_review_campaign_source_fetch_attempts +WHERE status = 'FETCHING' + AND updated_at < @stale_threshold +FOR UPDATE SKIP LOCKED +` + rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"stale_threshold": staleThreshold}) + if err != nil { + return 0, fmt.Errorf("cannot query stale fetch attempts: %w", err) + } + + stale, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewCampaignSourceFetchAttempt]) + if err != nil { + return 0, fmt.Errorf("cannot collect stale fetch attempts: %w", err) + } + + staleMessage := "fetch timed out" + + for _, attempt := range stale { + scope := NewScope(attempt.TenantID) + + attempt.Status = AccessReviewCampaignSourceFetchStatusFailed + attempt.Error = &staleMessage + attempt.CompletedAt = &now + attempt.UpdatedAt = now + + if err := attempt.Update(ctx, conn, scope); err != nil { + return 0, fmt.Errorf("cannot fail stale fetch attempt: %w", err) + } + + retry := &AccessReviewCampaignSourceFetchAttempt{ + ID: gid.New(attempt.TenantID, AccessReviewCampaignSourceFetchAttemptEntityType), + AccessReviewCampaignSourceID: attempt.AccessReviewCampaignSourceID, + Status: AccessReviewCampaignSourceFetchStatusQueued, + CreatedAt: now, + UpdatedAt: now, + } + + if err := retry.Insert(ctx, conn, scope); err != nil { + return 0, fmt.Errorf("cannot queue retry fetch attempt: %w", err) + } + } + + return len(stale), nil +} diff --git a/pkg/coredata/access_review_campaign_source_test.go b/pkg/coredata/access_review_campaign_source_test.go new file mode 100644 index 000000000..0212afd4e --- /dev/null +++ b/pkg/coredata/access_review_campaign_source_test.go @@ -0,0 +1,158 @@ +// Copyright (c) 2026 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_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/internal/test" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +func insertAccessReviewEntry(t *testing.T, ctx context.Context, client *pg.Client, fx accessEntryFixture, accountKey string) gid.GID { + t.Helper() + + tenantID := fx.scope.GetTenantID() + now := time.Now().UTC().Truncate(time.Microsecond) + entryID := gid.New(tenantID, coredata.AccessReviewEntryEntityType) + + entry := &coredata.AccessReviewEntry{ + ID: entryID, + OrganizationID: fx.organizationID, + AccessReviewCampaignID: fx.campaignID, + AccessReviewCampaignSourceID: fx.campaignSourceID, + Email: accountKey, + FullName: "Snapshot User", + Role: "member", + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, + ExternalID: "ext-snapshot", + AccountKey: accountKey, + IncrementalTag: coredata.AccessReviewEntryIncrementalTagNew, + Flags: []coredata.AccessReviewEntryFlag{}, + FlagReasons: []string{}, + Decision: coredata.AccessReviewEntryDecisionPending, + CreatedAt: now, + UpdatedAt: now, + } + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + return entry.Upsert(ctx, tx, fx.scope) + })) + + return entryID +} + +// TestAccessReviewSourceDeletion_PreservesSnapshotAndEntries verifies the core +// archival guarantee: deleting the live access source nulls the snapshot link +// (ON DELETE SET NULL) but keeps the per-campaign snapshot and its entries. +func TestAccessReviewSourceDeletion_PreservesSnapshotAndEntries(t *testing.T) { + t.Parallel() + + client := test.PGClient(t) + ctx := context.Background() + fx := seedAccessReviewEntryFixture(t, ctx, client) + + entryID := insertAccessReviewEntry(t, ctx, client, fx, "preserve-me@example.com") + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + _, err := tx.Exec(ctx, `DELETE FROM access_review_sources WHERE id = $1`, fx.sourceID) + return err + })) + + loadedEntry := &coredata.AccessReviewEntry{} + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return loadedEntry.LoadByID(ctx, conn, fx.scope, entryID) + })) + assert.Equal(t, "preserve-me@example.com", loadedEntry.Email, "entry must survive source deletion") + + loadedSource := &coredata.AccessReviewCampaignSource{} + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return loadedSource.LoadByID(ctx, conn, fx.scope, fx.campaignSourceID) + })) + assert.Nil(t, loadedSource.AccessReviewSourceID, "snapshot link must be nulled, not cascaded") + assert.Equal(t, "Upsert Freeze Test Source", loadedSource.Name, "snapshot name must be preserved") +} + +// TestSourceFetchAttempts_AppendOnly verifies attempts accumulate as an +// append-only log and that the latest attempt reflects the most recent run. +func TestSourceFetchAttempts_AppendOnly(t *testing.T) { + t.Parallel() + + client := test.PGClient(t) + ctx := context.Background() + fx := seedAccessReviewEntryFixture(t, ctx, client) + + tenantID := fx.scope.GetTenantID() + now := time.Now().UTC().Truncate(time.Microsecond) + failureMsg := "We couldn't fetch accounts from this source." + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + first := &coredata.AccessReviewCampaignSourceFetchAttempt{ + ID: gid.New(tenantID, coredata.AccessReviewCampaignSourceFetchAttemptEntityType), + AccessReviewCampaignSourceID: fx.campaignSourceID, + Status: coredata.AccessReviewCampaignSourceFetchStatusFailed, + Error: &failureMsg, + CompletedAt: &now, + CreatedAt: now, + UpdatedAt: now, + } + if err := first.Insert(ctx, tx, fx.scope); err != nil { + return err + } + require.Equal(t, 1, first.AttemptNumber) + + second := &coredata.AccessReviewCampaignSourceFetchAttempt{ + ID: gid.New(tenantID, coredata.AccessReviewCampaignSourceFetchAttemptEntityType), + AccessReviewCampaignSourceID: fx.campaignSourceID, + Status: coredata.AccessReviewCampaignSourceFetchStatusSuccess, + FetchedAccountsCount: 7, + CompletedAt: &now, + CreatedAt: now.Add(time.Minute), + UpdatedAt: now.Add(time.Minute), + } + if err := second.Insert(ctx, tx, fx.scope); err != nil { + return err + } + require.Equal(t, 2, second.AttemptNumber) + + return nil + })) + + var history coredata.AccessReviewCampaignSourceFetchAttempts + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return history.LoadByCampaignSourceID(ctx, conn, fx.scope, fx.campaignSourceID) + })) + require.Len(t, history, 2, "both attempts must be retained") + assert.Equal(t, 2, history[0].AttemptNumber, "history is newest first") + assert.Equal(t, coredata.AccessReviewCampaignSourceFetchStatusFailed, history[1].Status) + require.NotNil(t, history[1].Error) + assert.Equal(t, failureMsg, *history[1].Error, "the failed attempt's error is retained") + + var latest coredata.AccessReviewCampaignSourceFetchAttempts + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return latest.LoadLatestByCampaignID(ctx, conn, fx.scope, fx.campaignID) + })) + require.Len(t, latest, 1, "one latest attempt per snapshot") + assert.Equal(t, coredata.AccessReviewCampaignSourceFetchStatusSuccess, latest[0].Status) + assert.Equal(t, 7, latest[0].FetchedAccountsCount) +} diff --git a/pkg/coredata/access_entry.go b/pkg/coredata/access_review_entry.go similarity index 64% rename from pkg/coredata/access_entry.go rename to pkg/coredata/access_review_entry.go index 8f5315953..a3e6f275c 100644 --- a/pkg/coredata/access_entry.go +++ b/pkg/coredata/access_review_entry.go @@ -29,54 +29,54 @@ import ( ) type ( - AccessEntry struct { - ID gid.GID `db:"id"` - OrganizationID gid.GID `db:"organization_id"` - AccessReviewCampaignID gid.GID `db:"access_review_campaign_id"` - AccessSourceID gid.GID `db:"access_source_id"` - IdentityID *gid.GID `db:"identity_id"` - Email string `db:"email"` - FullName string `db:"full_name"` - Role string `db:"role"` - JobTitle string `db:"job_title"` - IsAdmin bool `db:"is_admin"` - MFAStatus MFAStatus `db:"mfa_status"` - AuthMethod AccessEntryAuthMethod `db:"auth_method"` - AccountType AccessEntryAccountType `db:"account_type"` - Active *bool `db:"active"` - LastLogin *time.Time `db:"last_login"` - AccountCreatedAt *time.Time `db:"account_created_at"` - ExternalID string `db:"external_id"` - AccountKey string `db:"account_key"` - IncrementalTag AccessEntryIncrementalTag `db:"incremental_tag"` - Flags []AccessEntryFlag `db:"flags"` - FlagReasons []string `db:"flag_reasons"` - Decision AccessEntryDecision `db:"decision"` - DecisionNote *string `db:"decision_note"` - DecidedBy *gid.GID `db:"decided_by"` - DecidedAt *time.Time `db:"decided_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + AccessReviewEntry struct { + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + AccessReviewCampaignID gid.GID `db:"access_review_campaign_id"` + AccessReviewCampaignSourceID gid.GID `db:"access_review_campaign_source_id"` + IdentityID *gid.GID `db:"identity_id"` + Email string `db:"email"` + FullName string `db:"full_name"` + Role string `db:"role"` + JobTitle string `db:"job_title"` + IsAdmin bool `db:"is_admin"` + MFAStatus MFAStatus `db:"mfa_status"` + AuthMethod AccessReviewEntryAuthMethod `db:"auth_method"` + AccountType AccessReviewEntryAccountType `db:"account_type"` + Active *bool `db:"active"` + LastLogin *time.Time `db:"last_login"` + AccountCreatedAt *time.Time `db:"account_created_at"` + ExternalID string `db:"external_id"` + AccountKey string `db:"account_key"` + IncrementalTag AccessReviewEntryIncrementalTag `db:"incremental_tag"` + Flags []AccessReviewEntryFlag `db:"flags"` + FlagReasons []string `db:"flag_reasons"` + Decision AccessReviewEntryDecision `db:"decision"` + DecisionNote *string `db:"decision_note"` + DecidedBy *gid.GID `db:"decided_by"` + DecidedAt *time.Time `db:"decided_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } - AccessEntries []*AccessEntry + AccessReviewEntries []*AccessReviewEntry ) -func (e AccessEntry) CursorKey(orderBy AccessEntryOrderField) page.CursorKey { +func (e AccessReviewEntry) CursorKey(orderBy AccessReviewEntryOrderField) page.CursorKey { switch orderBy { - case AccessEntryOrderFieldCreatedAt: + case AccessReviewEntryOrderFieldCreatedAt: return page.NewCursorKey(e.ID, e.CreatedAt) } panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (e *AccessEntry) AuthorizationAttributes( +func (e *AccessReviewEntry) AuthorizationAttributes( ctx context.Context, conn pg.Querier, resourceIDs []gid.GID, ) (policy.AttributesByID, error) { - q := `SELECT id, organization_id FROM access_entries WHERE id = ANY(@resource_ids::text[])` + q := `SELECT id, organization_id FROM access_review_entries WHERE id = ANY(@resource_ids::text[])` args := pgx.StrictNamedArgs{ "resource_ids": resourceIDs, @@ -110,7 +110,7 @@ func (e *AccessEntry) AuthorizationAttributes( return attrsByID, nil } -func (e *AccessEntry) LoadByID( +func (e *AccessReviewEntry) LoadByID( ctx context.Context, conn pg.Querier, scope Scoper, @@ -121,7 +121,7 @@ SELECT id, organization_id, access_review_campaign_id, - access_source_id, + access_review_campaign_source_id, identity_id, email, full_name, @@ -146,7 +146,7 @@ SELECT created_at, updated_at FROM - access_entries + access_review_entries WHERE %s AND id = @id @@ -159,10 +159,10 @@ LIMIT 1; rows, err := conn.Query(ctx, q, args) if err != nil { - return fmt.Errorf("cannot query access_entries: %w", err) + return fmt.Errorf("cannot query access_review_entries: %w", err) } - entry, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessEntry]) + entry, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessReviewEntry]) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return ErrResourceNotFound @@ -176,19 +176,19 @@ LIMIT 1; return nil } -func (e *AccessEntry) Insert( +func (e *AccessReviewEntry) Insert( ctx context.Context, conn pg.Tx, scope Scoper, ) error { q := ` INSERT INTO - access_entries ( + access_review_entries ( id, tenant_id, organization_id, access_review_campaign_id, - access_source_id, + access_review_campaign_source_id, identity_id, email, full_name, @@ -218,7 +218,7 @@ VALUES ( @tenant_id, @organization_id, @access_review_campaign_id, - @access_source_id, + @access_review_campaign_source_id, @identity_id, @email, @full_name, @@ -246,34 +246,34 @@ VALUES ( ` args := pgx.StrictNamedArgs{ - "id": e.ID, - "tenant_id": scope.GetTenantID(), - "organization_id": e.OrganizationID, - "access_review_campaign_id": e.AccessReviewCampaignID, - "access_source_id": e.AccessSourceID, - "identity_id": e.IdentityID, - "email": e.Email, - "full_name": e.FullName, - "role": e.Role, - "job_title": e.JobTitle, - "is_admin": e.IsAdmin, - "mfa_status": e.MFAStatus, - "auth_method": e.AuthMethod, - "account_type": e.AccountType, - "active": e.Active, - "last_login": e.LastLogin, - "account_created_at": e.AccountCreatedAt, - "external_id": e.ExternalID, - "account_key": e.AccountKey, - "incremental_tag": e.IncrementalTag, - "flags": e.Flags, - "flag_reasons": e.FlagReasons, - "decision": e.Decision, - "decision_note": e.DecisionNote, - "decided_by": e.DecidedBy, - "decided_at": e.DecidedAt, - "created_at": e.CreatedAt, - "updated_at": e.UpdatedAt, + "id": e.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": e.OrganizationID, + "access_review_campaign_id": e.AccessReviewCampaignID, + "access_review_campaign_source_id": e.AccessReviewCampaignSourceID, + "identity_id": e.IdentityID, + "email": e.Email, + "full_name": e.FullName, + "role": e.Role, + "job_title": e.JobTitle, + "is_admin": e.IsAdmin, + "mfa_status": e.MFAStatus, + "auth_method": e.AuthMethod, + "account_type": e.AccountType, + "active": e.Active, + "last_login": e.LastLogin, + "account_created_at": e.AccountCreatedAt, + "external_id": e.ExternalID, + "account_key": e.AccountKey, + "incremental_tag": e.IncrementalTag, + "flags": e.Flags, + "flag_reasons": e.FlagReasons, + "decision": e.Decision, + "decision_note": e.DecisionNote, + "decided_by": e.DecidedBy, + "decided_at": e.DecidedAt, + "created_at": e.CreatedAt, + "updated_at": e.UpdatedAt, } _, err := conn.Exec(ctx, q, args) @@ -284,13 +284,13 @@ VALUES ( return nil } -func (e *AccessEntry) Update( +func (e *AccessReviewEntry) Update( ctx context.Context, conn pg.Tx, scope Scoper, ) error { q := ` -UPDATE access_entries +UPDATE access_review_entries SET flags = @flags, flag_reasons = @flag_reasons, @@ -329,20 +329,20 @@ WHERE return nil } -func (entries *AccessEntries) LoadByCampaignID( +func (entries *AccessReviewEntries) LoadByCampaignID( ctx context.Context, conn pg.Querier, scope Scoper, campaignID gid.GID, - cursor *page.Cursor[AccessEntryOrderField], - filter *AccessEntryFilter, + cursor *page.Cursor[AccessReviewEntryOrderField], + filter *AccessReviewEntryFilter, ) error { q := ` SELECT id, organization_id, access_review_campaign_id, - access_source_id, + access_review_campaign_source_id, identity_id, email, full_name, @@ -367,7 +367,7 @@ SELECT created_at, updated_at FROM - access_entries + access_review_entries WHERE %s AND access_review_campaign_id = @campaign_id @@ -383,12 +383,12 @@ WHERE rows, err := conn.Query(ctx, q, args) if err != nil { - return fmt.Errorf("cannot query access_entries: %w", err) + return fmt.Errorf("cannot query access_review_entries: %w", err) } - result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessEntry]) + result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewEntry]) if err != nil { - return fmt.Errorf("cannot collect access_entries: %w", err) + return fmt.Errorf("cannot collect access_review_entries: %w", err) } *entries = result @@ -396,21 +396,21 @@ WHERE return nil } -func (entries *AccessEntries) LoadByCampaignIDAndSourceID( +func (entries *AccessReviewEntries) LoadByCampaignIDAndSourceID( ctx context.Context, conn pg.Querier, scope Scoper, campaignID gid.GID, sourceID gid.GID, - cursor *page.Cursor[AccessEntryOrderField], - filter *AccessEntryFilter, + cursor *page.Cursor[AccessReviewEntryOrderField], + filter *AccessReviewEntryFilter, ) error { q := ` SELECT id, organization_id, access_review_campaign_id, - access_source_id, + access_review_campaign_source_id, identity_id, email, full_name, @@ -435,11 +435,11 @@ SELECT created_at, updated_at FROM - access_entries + access_review_entries WHERE %s AND access_review_campaign_id = @campaign_id - AND access_source_id = @source_id + AND access_review_campaign_source_id = @source_id AND %s AND %s ` @@ -452,12 +452,12 @@ WHERE rows, err := conn.Query(ctx, q, args) if err != nil { - return fmt.Errorf("cannot query access_entries: %w", err) + return fmt.Errorf("cannot query access_review_entries: %w", err) } - result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessEntry]) + result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewEntry]) if err != nil { - return fmt.Errorf("cannot collect access_entries: %w", err) + return fmt.Errorf("cannot collect access_review_entries: %w", err) } *entries = result @@ -465,16 +465,16 @@ WHERE return nil } -func (entries *AccessEntries) CountByCampaignID( +func (entries *AccessReviewEntries) CountByCampaignID( ctx context.Context, conn pg.Querier, scope Scoper, campaignID gid.GID, - filter *AccessEntryFilter, + filter *AccessReviewEntryFilter, ) (int, error) { q := ` SELECT COUNT(id) -FROM access_entries +FROM access_review_entries WHERE %s AND access_review_campaign_id = @campaign_id @@ -488,27 +488,27 @@ WHERE var count int if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { - return 0, fmt.Errorf("cannot count access_entries: %w", err) + return 0, fmt.Errorf("cannot count access_review_entries: %w", err) } return count, nil } -func (entries *AccessEntries) CountByCampaignIDAndSourceID( +func (entries *AccessReviewEntries) CountByCampaignIDAndSourceID( ctx context.Context, conn pg.Querier, scope Scoper, campaignID gid.GID, sourceID gid.GID, - filter *AccessEntryFilter, + filter *AccessReviewEntryFilter, ) (int, error) { q := ` SELECT COUNT(id) -FROM access_entries +FROM access_review_entries WHERE %s AND access_review_campaign_id = @campaign_id - AND access_source_id = @source_id + AND access_review_campaign_source_id = @source_id AND %s; ` q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment()) @@ -519,13 +519,13 @@ WHERE var count int if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { - return 0, fmt.Errorf("cannot count access_entries: %w", err) + return 0, fmt.Errorf("cannot count access_review_entries: %w", err) } return count, nil } -func (entries *AccessEntries) CountPendingByCampaignID( +func (entries *AccessReviewEntries) CountPendingByCampaignID( ctx context.Context, conn pg.Querier, scope Scoper, @@ -533,7 +533,7 @@ func (entries *AccessEntries) CountPendingByCampaignID( ) (int, error) { q := ` SELECT COUNT(id) -FROM access_entries +FROM access_review_entries WHERE %s AND access_review_campaign_id = @campaign_id @@ -546,18 +546,18 @@ WHERE var count int if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { - return 0, fmt.Errorf("cannot count pending access_entries: %w", err) + return 0, fmt.Errorf("cannot count pending access_review_entries: %w", err) } return count, nil } -func (e *AccessEntry) LoadOrganizationID( +func (e *AccessReviewEntry) LoadOrganizationID( ctx context.Context, conn pg.Querier, entryID gid.GID, ) (gid.GID, error) { - q := `SELECT organization_id FROM access_entries WHERE id = $1 LIMIT 1;` + q := `SELECT organization_id FROM access_review_entries WHERE id = $1 LIMIT 1;` var organizationID gid.GID if err := conn.QueryRow(ctx, q, entryID).Scan(&organizationID); err != nil { @@ -571,13 +571,13 @@ func (e *AccessEntry) LoadOrganizationID( return organizationID, nil } -func (e *AccessEntry) UpdateFlags( +func (e *AccessReviewEntry) UpdateFlags( ctx context.Context, conn pg.Tx, scope Scoper, ) error { q := ` -UPDATE access_entries +UPDATE access_review_entries SET flags = @flags, flag_reasons = @flag_reasons, @@ -614,19 +614,19 @@ WHERE // intentionally absent from the ON CONFLICT DO UPDATE SET clause, so an // existing row's verdict survives every subsequent source poll untouched. // Those columns are written on the initial INSERT (new row) and can only be -// changed afterwards through AccessEntry.Update. -func (e *AccessEntry) Upsert( +// changed afterwards through AccessReviewEntry.Update. +func (e *AccessReviewEntry) Upsert( ctx context.Context, conn pg.Tx, scope Scoper, ) error { q := ` -INSERT INTO access_entries ( +INSERT INTO access_review_entries ( id, tenant_id, organization_id, access_review_campaign_id, - access_source_id, + access_review_campaign_source_id, identity_id, email, full_name, @@ -655,7 +655,7 @@ INSERT INTO access_entries ( @tenant_id, @organization_id, @access_review_campaign_id, - @access_source_id, + @access_review_campaign_source_id, @identity_id, @email, @full_name, @@ -680,7 +680,7 @@ INSERT INTO access_entries ( @created_at, @updated_at ) -ON CONFLICT (access_review_campaign_id, access_source_id, account_key) DO UPDATE SET +ON CONFLICT (access_review_campaign_source_id, account_key) DO UPDATE SET email = EXCLUDED.email, full_name = EXCLUDED.full_name, role = EXCLUDED.role, @@ -698,34 +698,34 @@ ON CONFLICT (access_review_campaign_id, access_source_id, account_key) DO UPDATE ` args := pgx.StrictNamedArgs{ - "id": e.ID, - "tenant_id": scope.GetTenantID(), - "organization_id": e.OrganizationID, - "access_review_campaign_id": e.AccessReviewCampaignID, - "access_source_id": e.AccessSourceID, - "identity_id": e.IdentityID, - "email": e.Email, - "full_name": e.FullName, - "role": e.Role, - "job_title": e.JobTitle, - "is_admin": e.IsAdmin, - "mfa_status": e.MFAStatus, - "auth_method": e.AuthMethod, - "account_type": e.AccountType, - "active": e.Active, - "last_login": e.LastLogin, - "account_created_at": e.AccountCreatedAt, - "external_id": e.ExternalID, - "account_key": e.AccountKey, - "incremental_tag": e.IncrementalTag, - "flags": e.Flags, - "flag_reasons": e.FlagReasons, - "decision": e.Decision, - "decision_note": e.DecisionNote, - "decided_by": e.DecidedBy, - "decided_at": e.DecidedAt, - "created_at": e.CreatedAt, - "updated_at": e.UpdatedAt, + "id": e.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": e.OrganizationID, + "access_review_campaign_id": e.AccessReviewCampaignID, + "access_review_campaign_source_id": e.AccessReviewCampaignSourceID, + "identity_id": e.IdentityID, + "email": e.Email, + "full_name": e.FullName, + "role": e.Role, + "job_title": e.JobTitle, + "is_admin": e.IsAdmin, + "mfa_status": e.MFAStatus, + "auth_method": e.AuthMethod, + "account_type": e.AccountType, + "active": e.Active, + "last_login": e.LastLogin, + "account_created_at": e.AccountCreatedAt, + "external_id": e.ExternalID, + "account_key": e.AccountKey, + "incremental_tag": e.IncrementalTag, + "flags": e.Flags, + "flag_reasons": e.FlagReasons, + "decision": e.Decision, + "decision_note": e.DecisionNote, + "decided_by": e.DecidedBy, + "decided_at": e.DecidedAt, + "created_at": e.CreatedAt, + "updated_at": e.UpdatedAt, } if _, err := conn.Exec(ctx, q, args); err != nil { @@ -743,7 +743,7 @@ type BaselineAccountEntry struct { FullName string } -func (entries *AccessEntries) LoadBaselineBySourceID( +func (entries *AccessReviewEntries) LoadBaselineBySourceID( ctx context.Context, conn pg.Querier, scope Scoper, @@ -752,10 +752,14 @@ func (entries *AccessEntries) LoadBaselineBySourceID( ) ([]BaselineAccountEntry, error) { q := fmt.Sprintf(` SELECT account_key, email, full_name -FROM access_entries +FROM access_review_entries WHERE %s - AND access_review_campaign_id = @campaign_id - AND access_source_id = @source_id + AND access_review_campaign_source_id IN ( + SELECT id + FROM access_review_campaign_sources + WHERE access_review_campaign_id = @campaign_id + AND access_review_source_id = @source_id + ) `, scope.SQLFragment()) args := pgx.StrictNamedArgs{ diff --git a/pkg/coredata/access_review_entry_account_type.go b/pkg/coredata/access_review_entry_account_type.go new file mode 100644 index 000000000..7d6c4cded --- /dev/null +++ b/pkg/coredata/access_review_entry_account_type.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 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 ( + "encoding" + "fmt" +) + +type AccessReviewEntryAccountType string + +const ( + AccessReviewEntryAccountTypeUser AccessReviewEntryAccountType = "USER" + AccessReviewEntryAccountTypeServiceAccount AccessReviewEntryAccountType = "SERVICE_ACCOUNT" +) + +var ( + _ fmt.Stringer = AccessReviewEntryAccountType("") + _ encoding.TextMarshaler = AccessReviewEntryAccountType("") + _ encoding.TextUnmarshaler = (*AccessReviewEntryAccountType)(nil) +) + +func AccessReviewEntryAccountTypes() []AccessReviewEntryAccountType { + return []AccessReviewEntryAccountType{ + AccessReviewEntryAccountTypeUser, + AccessReviewEntryAccountTypeServiceAccount, + } +} + +func (v AccessReviewEntryAccountType) IsValid() bool { + switch v { + case + AccessReviewEntryAccountTypeUser, + AccessReviewEntryAccountTypeServiceAccount: + return true + } + + return false +} + +func (v AccessReviewEntryAccountType) String() string { + return string(v) +} + +func (v AccessReviewEntryAccountType) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *AccessReviewEntryAccountType) UnmarshalText(text []byte) error { + val := AccessReviewEntryAccountType(text) + if !val.IsValid() { + return fmt.Errorf("invalid AccessReviewEntryAccountType value: %q", string(text)) + } + + *v = val + + return nil +} diff --git a/pkg/coredata/access_entry_account_type_test.go b/pkg/coredata/access_review_entry_account_type_test.go similarity index 78% rename from pkg/coredata/access_entry_account_type_test.go rename to pkg/coredata/access_review_entry_account_type_test.go index af5a71772..25faa9f8e 100644 --- a/pkg/coredata/access_entry_account_type_test.go +++ b/pkg/coredata/access_review_entry_account_type_test.go @@ -16,28 +16,28 @@ package coredata import "testing" -func TestAccessEntryAccountTypeIsValid(t *testing.T) { +func TestAccessReviewEntryAccountTypeIsValid(t *testing.T) { t.Parallel() - for _, value := range AccessEntryAccountTypes() { + for _, value := range AccessReviewEntryAccountTypes() { if !value.IsValid() { t.Fatalf("IsValid() = false for %q", value) } } - if AccessEntryAccountType("BOGUS").IsValid() { + if AccessReviewEntryAccountType("BOGUS").IsValid() { t.Fatal("IsValid() = true for invalid value") } } -func TestAccessEntryAccountTypeUnmarshalText(t *testing.T) { +func TestAccessReviewEntryAccountTypeUnmarshalText(t *testing.T) { t.Parallel() - for _, value := range AccessEntryAccountTypes() { + for _, value := range AccessReviewEntryAccountTypes() { t.Run(string(value), func(t *testing.T) { t.Parallel() - var got AccessEntryAccountType + var got AccessReviewEntryAccountType if err := got.UnmarshalText([]byte(value)); err != nil { t.Fatalf("UnmarshalText(%q) returned error: %v", value, err) } @@ -51,17 +51,17 @@ func TestAccessEntryAccountTypeUnmarshalText(t *testing.T) { t.Run("invalid", func(t *testing.T) { t.Parallel() - var got AccessEntryAccountType + var got AccessReviewEntryAccountType if err := got.UnmarshalText([]byte("BOGUS")); err == nil { t.Fatal("UnmarshalText(BOGUS) expected error") } }) } -func TestAccessEntryAccountTypeMarshalText(t *testing.T) { +func TestAccessReviewEntryAccountTypeMarshalText(t *testing.T) { t.Parallel() - for _, value := range AccessEntryAccountTypes() { + for _, value := range AccessReviewEntryAccountTypes() { t.Run(string(value), func(t *testing.T) { t.Parallel() diff --git a/pkg/coredata/access_review_entry_decision.go b/pkg/coredata/access_review_entry_decision.go new file mode 100644 index 000000000..a3ef0c9ba --- /dev/null +++ b/pkg/coredata/access_review_entry_decision.go @@ -0,0 +1,79 @@ +// Copyright (c) 2025-2026 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 ( + "encoding" + "fmt" +) + +type AccessReviewEntryDecision string + +const ( + AccessReviewEntryDecisionPending AccessReviewEntryDecision = "PENDING" + AccessReviewEntryDecisionApproved AccessReviewEntryDecision = "APPROVED" + AccessReviewEntryDecisionRevoke AccessReviewEntryDecision = "REVOKE" + AccessReviewEntryDecisionDefer AccessReviewEntryDecision = "DEFER" + AccessReviewEntryDecisionEscalate AccessReviewEntryDecision = "ESCALATE" +) + +var ( + _ fmt.Stringer = AccessReviewEntryDecision("") + _ encoding.TextMarshaler = AccessReviewEntryDecision("") + _ encoding.TextUnmarshaler = (*AccessReviewEntryDecision)(nil) +) + +func AccessReviewEntryDecisions() []AccessReviewEntryDecision { + return []AccessReviewEntryDecision{ + AccessReviewEntryDecisionPending, + AccessReviewEntryDecisionApproved, + AccessReviewEntryDecisionRevoke, + AccessReviewEntryDecisionDefer, + AccessReviewEntryDecisionEscalate, + } +} + +func (v AccessReviewEntryDecision) IsValid() bool { + switch v { + case + AccessReviewEntryDecisionPending, + AccessReviewEntryDecisionApproved, + AccessReviewEntryDecisionRevoke, + AccessReviewEntryDecisionDefer, + AccessReviewEntryDecisionEscalate: + return true + } + + return false +} + +func (v AccessReviewEntryDecision) String() string { + return string(v) +} + +func (v AccessReviewEntryDecision) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *AccessReviewEntryDecision) UnmarshalText(text []byte) error { + val := AccessReviewEntryDecision(text) + if !val.IsValid() { + return fmt.Errorf("invalid AccessReviewEntryDecision value: %q", string(text)) + } + + *v = val + + return nil +} diff --git a/pkg/coredata/access_entry_decision_history.go b/pkg/coredata/access_review_entry_decision_history.go similarity index 64% rename from pkg/coredata/access_entry_decision_history.go rename to pkg/coredata/access_review_entry_decision_history.go index 8016eb9f4..ac5757c2e 100644 --- a/pkg/coredata/access_entry_decision_history.go +++ b/pkg/coredata/access_review_entry_decision_history.go @@ -27,31 +27,31 @@ import ( ) type ( - AccessEntryDecisionHistory struct { - ID gid.GID `db:"id"` - OrganizationID gid.GID `db:"organization_id"` - AccessEntry gid.GID `db:"access_entry_id"` - Decision AccessEntryDecision `db:"decision"` - DecisionNote *string `db:"decision_note"` - DecidedBy *gid.GID `db:"decided_by"` - DecidedAt time.Time `db:"decided_at"` - CreatedAt time.Time `db:"created_at"` + AccessReviewEntryDecisionHistory struct { + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + AccessReviewEntry gid.GID `db:"access_review_entry_id"` + Decision AccessReviewEntryDecision `db:"decision"` + DecisionNote *string `db:"decision_note"` + DecidedBy *gid.GID `db:"decided_by"` + DecidedAt time.Time `db:"decided_at"` + CreatedAt time.Time `db:"created_at"` } - AccessEntryDecisionHistories []*AccessEntryDecisionHistory + AccessReviewEntryDecisionHistories []*AccessReviewEntryDecisionHistory ) -func (h *AccessEntryDecisionHistory) Insert( +func (h *AccessReviewEntryDecisionHistory) Insert( ctx context.Context, conn pg.Tx, scope Scoper, ) error { q := ` -INSERT INTO access_entry_decision_history ( +INSERT INTO access_review_entry_decision_history ( id, tenant_id, organization_id, - access_entry_id, + access_review_entry_id, decision, decision_note, decided_by, @@ -61,7 +61,7 @@ INSERT INTO access_entry_decision_history ( @id, @tenant_id, @organization_id, - @access_entry_id, + @access_review_entry_id, @decision, @decision_note, @decided_by, @@ -70,15 +70,15 @@ INSERT INTO access_entry_decision_history ( ); ` args := pgx.StrictNamedArgs{ - "id": h.ID, - "tenant_id": scope.GetTenantID(), - "organization_id": h.OrganizationID, - "access_entry_id": h.AccessEntry, - "decision": h.Decision, - "decision_note": h.DecisionNote, - "decided_by": h.DecidedBy, - "decided_at": h.DecidedAt, - "created_at": h.CreatedAt, + "id": h.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": h.OrganizationID, + "access_review_entry_id": h.AccessReviewEntry, + "decision": h.Decision, + "decision_note": h.DecisionNote, + "decided_by": h.DecidedBy, + "decided_at": h.DecidedAt, + "created_at": h.CreatedAt, } _, err := conn.Exec(ctx, q, args) @@ -89,12 +89,12 @@ INSERT INTO access_entry_decision_history ( return nil } -func (h *AccessEntryDecisionHistory) AuthorizationAttributes( +func (h *AccessReviewEntryDecisionHistory) AuthorizationAttributes( ctx context.Context, conn pg.Querier, resourceIDs []gid.GID, ) (policy.AttributesByID, error) { - q := `SELECT id, organization_id FROM access_entry_decision_history WHERE id = ANY(@resource_ids::text[])` + q := `SELECT id, organization_id FROM access_review_entry_decision_history WHERE id = ANY(@resource_ids::text[])` args := pgx.StrictNamedArgs{ "resource_ids": resourceIDs, @@ -128,7 +128,7 @@ func (h *AccessEntryDecisionHistory) AuthorizationAttributes( return attrsByID, nil } -func (hs *AccessEntryDecisionHistories) LoadByEntryID( +func (hs *AccessReviewEntryDecisionHistories) LoadByEntryID( ctx context.Context, conn pg.Querier, scope Scoper, @@ -138,22 +138,22 @@ func (hs *AccessEntryDecisionHistories) LoadByEntryID( SELECT id, organization_id, - access_entry_id, + access_review_entry_id, decision, decision_note, decided_by, decided_at, created_at FROM - access_entry_decision_history + access_review_entry_decision_history WHERE %s - AND access_entry_id = @access_entry_id + AND access_review_entry_id = @access_review_entry_id ORDER BY decided_at ASC; ` q = fmt.Sprintf(q, scope.SQLFragment()) - args := pgx.StrictNamedArgs{"access_entry_id": entryID} + args := pgx.StrictNamedArgs{"access_review_entry_id": entryID} maps.Copy(args, scope.SQLArguments()) rows, err := conn.Query(ctx, q, args) @@ -161,7 +161,7 @@ ORDER BY decided_at ASC; return fmt.Errorf("cannot query access entry decision history: %w", err) } - result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessEntryDecisionHistory]) + result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewEntryDecisionHistory]) if err != nil { return fmt.Errorf("cannot collect access entry decision history: %w", err) } diff --git a/pkg/coredata/access_entry_flag_test.go b/pkg/coredata/access_review_entry_decision_test.go similarity index 79% rename from pkg/coredata/access_entry_flag_test.go rename to pkg/coredata/access_review_entry_decision_test.go index cb9c9ba61..96a8bfd7c 100644 --- a/pkg/coredata/access_entry_flag_test.go +++ b/pkg/coredata/access_review_entry_decision_test.go @@ -16,28 +16,28 @@ package coredata import "testing" -func TestAccessEntryFlagIsValid(t *testing.T) { +func TestAccessReviewEntryDecisionIsValid(t *testing.T) { t.Parallel() - for _, value := range AccessEntryFlags() { + for _, value := range AccessReviewEntryDecisions() { if !value.IsValid() { t.Fatalf("IsValid() = false for %q", value) } } - if AccessEntryFlag("BOGUS").IsValid() { + if AccessReviewEntryDecision("BOGUS").IsValid() { t.Fatal("IsValid() = true for invalid value") } } -func TestAccessEntryFlagUnmarshalText(t *testing.T) { +func TestAccessReviewEntryDecisionUnmarshalText(t *testing.T) { t.Parallel() - for _, value := range AccessEntryFlags() { + for _, value := range AccessReviewEntryDecisions() { t.Run(string(value), func(t *testing.T) { t.Parallel() - var got AccessEntryFlag + var got AccessReviewEntryDecision if err := got.UnmarshalText([]byte(value)); err != nil { t.Fatalf("UnmarshalText(%q) returned error: %v", value, err) } @@ -51,17 +51,17 @@ func TestAccessEntryFlagUnmarshalText(t *testing.T) { t.Run("invalid", func(t *testing.T) { t.Parallel() - var got AccessEntryFlag + var got AccessReviewEntryDecision if err := got.UnmarshalText([]byte("BOGUS")); err == nil { t.Fatal("UnmarshalText(BOGUS) expected error") } }) } -func TestAccessEntryFlagMarshalText(t *testing.T) { +func TestAccessReviewEntryDecisionMarshalText(t *testing.T) { t.Parallel() - for _, value := range AccessEntryFlags() { + for _, value := range AccessReviewEntryDecisions() { t.Run(string(value), func(t *testing.T) { t.Parallel() diff --git a/pkg/coredata/access_entry_filter.go b/pkg/coredata/access_review_entry_filter.go similarity index 87% rename from pkg/coredata/access_entry_filter.go rename to pkg/coredata/access_review_entry_filter.go index 71e6e406a..467fa08dd 100644 --- a/pkg/coredata/access_entry_filter.go +++ b/pkg/coredata/access_review_entry_filter.go @@ -18,17 +18,17 @@ import ( "github.com/jackc/pgx/v5" ) -type AccessEntryFilter struct { - Decision *AccessEntryDecision - Flag *AccessEntryFlag - IncrementalTag *AccessEntryIncrementalTag +type AccessReviewEntryFilter struct { + Decision *AccessReviewEntryDecision + Flag *AccessReviewEntryFlag + IncrementalTag *AccessReviewEntryIncrementalTag IsAdmin *bool Active *bool - AuthMethod *AccessEntryAuthMethod - AccountType *AccessEntryAccountType + AuthMethod *AccessReviewEntryAuthMethod + AccountType *AccessReviewEntryAccountType } -func (f *AccessEntryFilter) SQLFragment() string { +func (f *AccessReviewEntryFilter) SQLFragment() string { if f == nil { return "TRUE" } @@ -79,7 +79,7 @@ func (f *AccessEntryFilter) SQLFragment() string { )` } -func (f *AccessEntryFilter) SQLArguments() pgx.StrictNamedArgs { +func (f *AccessReviewEntryFilter) SQLArguments() pgx.StrictNamedArgs { if f == nil { return pgx.StrictNamedArgs{} } diff --git a/pkg/coredata/access_review_entry_flag.go b/pkg/coredata/access_review_entry_flag.go new file mode 100644 index 000000000..31320806e --- /dev/null +++ b/pkg/coredata/access_review_entry_flag.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026 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 ( + "encoding" + "fmt" +) + +type AccessReviewEntryFlag string + +const ( + AccessReviewEntryFlagNone AccessReviewEntryFlag = "NONE" + AccessReviewEntryFlagOrphaned AccessReviewEntryFlag = "ORPHANED" + AccessReviewEntryFlagInactive AccessReviewEntryFlag = "INACTIVE" + AccessReviewEntryFlagExcessive AccessReviewEntryFlag = "EXCESSIVE" + AccessReviewEntryFlagRoleMismatch AccessReviewEntryFlag = "ROLE_MISMATCH" + AccessReviewEntryFlagNew AccessReviewEntryFlag = "NEW" + AccessReviewEntryFlagDormant AccessReviewEntryFlag = "DORMANT" + AccessReviewEntryFlagTerminatedUser AccessReviewEntryFlag = "TERMINATED_USER" + AccessReviewEntryFlagContractorExpired AccessReviewEntryFlag = "CONTRACTOR_EXPIRED" + AccessReviewEntryFlagSoDConflict AccessReviewEntryFlag = "SOD_CONFLICT" + AccessReviewEntryFlagPrivilegedAccess AccessReviewEntryFlag = "PRIVILEGED_ACCESS" + AccessReviewEntryFlagRoleCreep AccessReviewEntryFlag = "ROLE_CREEP" + AccessReviewEntryFlagNoBusinessJustification AccessReviewEntryFlag = "NO_BUSINESS_JUSTIFICATION" + AccessReviewEntryFlagOutOfDepartment AccessReviewEntryFlag = "OUT_OF_DEPARTMENT" + AccessReviewEntryFlagSharedAccount AccessReviewEntryFlag = "SHARED_ACCOUNT" +) + +var ( + _ fmt.Stringer = AccessReviewEntryFlag("") + _ encoding.TextMarshaler = AccessReviewEntryFlag("") + _ encoding.TextUnmarshaler = (*AccessReviewEntryFlag)(nil) +) + +func AccessReviewEntryFlags() []AccessReviewEntryFlag { + return []AccessReviewEntryFlag{ + AccessReviewEntryFlagNone, + AccessReviewEntryFlagOrphaned, + AccessReviewEntryFlagInactive, + AccessReviewEntryFlagExcessive, + AccessReviewEntryFlagRoleMismatch, + AccessReviewEntryFlagNew, + AccessReviewEntryFlagDormant, + AccessReviewEntryFlagTerminatedUser, + AccessReviewEntryFlagContractorExpired, + AccessReviewEntryFlagSoDConflict, + AccessReviewEntryFlagPrivilegedAccess, + AccessReviewEntryFlagRoleCreep, + AccessReviewEntryFlagNoBusinessJustification, + AccessReviewEntryFlagOutOfDepartment, + AccessReviewEntryFlagSharedAccount, + } +} + +func (v AccessReviewEntryFlag) IsValid() bool { + switch v { + case + AccessReviewEntryFlagNone, + AccessReviewEntryFlagOrphaned, + AccessReviewEntryFlagInactive, + AccessReviewEntryFlagExcessive, + AccessReviewEntryFlagRoleMismatch, + AccessReviewEntryFlagNew, + AccessReviewEntryFlagDormant, + AccessReviewEntryFlagTerminatedUser, + AccessReviewEntryFlagContractorExpired, + AccessReviewEntryFlagSoDConflict, + AccessReviewEntryFlagPrivilegedAccess, + AccessReviewEntryFlagRoleCreep, + AccessReviewEntryFlagNoBusinessJustification, + AccessReviewEntryFlagOutOfDepartment, + AccessReviewEntryFlagSharedAccount: + return true + } + + return false +} + +func (v AccessReviewEntryFlag) String() string { + return string(v) +} + +func (v AccessReviewEntryFlag) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *AccessReviewEntryFlag) UnmarshalText(text []byte) error { + val := AccessReviewEntryFlag(text) + if !val.IsValid() { + return fmt.Errorf("invalid AccessReviewEntryFlag value: %q", string(text)) + } + + *v = val + + return nil +} diff --git a/pkg/coredata/access_entry_decision_test.go b/pkg/coredata/access_review_entry_flag_test.go similarity index 80% rename from pkg/coredata/access_entry_decision_test.go rename to pkg/coredata/access_review_entry_flag_test.go index 4218848b3..d2eae01fb 100644 --- a/pkg/coredata/access_entry_decision_test.go +++ b/pkg/coredata/access_review_entry_flag_test.go @@ -16,28 +16,28 @@ package coredata import "testing" -func TestAccessEntryDecisionIsValid(t *testing.T) { +func TestAccessReviewEntryFlagIsValid(t *testing.T) { t.Parallel() - for _, value := range AccessEntryDecisions() { + for _, value := range AccessReviewEntryFlags() { if !value.IsValid() { t.Fatalf("IsValid() = false for %q", value) } } - if AccessEntryDecision("BOGUS").IsValid() { + if AccessReviewEntryFlag("BOGUS").IsValid() { t.Fatal("IsValid() = true for invalid value") } } -func TestAccessEntryDecisionUnmarshalText(t *testing.T) { +func TestAccessReviewEntryFlagUnmarshalText(t *testing.T) { t.Parallel() - for _, value := range AccessEntryDecisions() { + for _, value := range AccessReviewEntryFlags() { t.Run(string(value), func(t *testing.T) { t.Parallel() - var got AccessEntryDecision + var got AccessReviewEntryFlag if err := got.UnmarshalText([]byte(value)); err != nil { t.Fatalf("UnmarshalText(%q) returned error: %v", value, err) } @@ -51,17 +51,17 @@ func TestAccessEntryDecisionUnmarshalText(t *testing.T) { t.Run("invalid", func(t *testing.T) { t.Parallel() - var got AccessEntryDecision + var got AccessReviewEntryFlag if err := got.UnmarshalText([]byte("BOGUS")); err == nil { t.Fatal("UnmarshalText(BOGUS) expected error") } }) } -func TestAccessEntryDecisionMarshalText(t *testing.T) { +func TestAccessReviewEntryFlagMarshalText(t *testing.T) { t.Parallel() - for _, value := range AccessEntryDecisions() { + for _, value := range AccessReviewEntryFlags() { t.Run(string(value), func(t *testing.T) { t.Parallel() diff --git a/pkg/coredata/access_review_entry_incremental_tag.go b/pkg/coredata/access_review_entry_incremental_tag.go new file mode 100644 index 000000000..e9be2cefe --- /dev/null +++ b/pkg/coredata/access_review_entry_incremental_tag.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 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 ( + "encoding" + "fmt" +) + +type AccessReviewEntryIncrementalTag string + +const ( + AccessReviewEntryIncrementalTagNew AccessReviewEntryIncrementalTag = "NEW" + AccessReviewEntryIncrementalTagRemoved AccessReviewEntryIncrementalTag = "REMOVED" + AccessReviewEntryIncrementalTagUnchanged AccessReviewEntryIncrementalTag = "UNCHANGED" +) + +var ( + _ fmt.Stringer = AccessReviewEntryIncrementalTag("") + _ encoding.TextMarshaler = AccessReviewEntryIncrementalTag("") + _ encoding.TextUnmarshaler = (*AccessReviewEntryIncrementalTag)(nil) +) + +func AccessReviewEntryIncrementalTags() []AccessReviewEntryIncrementalTag { + return []AccessReviewEntryIncrementalTag{ + AccessReviewEntryIncrementalTagNew, + AccessReviewEntryIncrementalTagRemoved, + AccessReviewEntryIncrementalTagUnchanged, + } +} + +func (v AccessReviewEntryIncrementalTag) IsValid() bool { + switch v { + case + AccessReviewEntryIncrementalTagNew, + AccessReviewEntryIncrementalTagRemoved, + AccessReviewEntryIncrementalTagUnchanged: + return true + } + + return false +} + +func (v AccessReviewEntryIncrementalTag) String() string { + return string(v) +} + +func (v AccessReviewEntryIncrementalTag) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *AccessReviewEntryIncrementalTag) UnmarshalText(text []byte) error { + val := AccessReviewEntryIncrementalTag(text) + if !val.IsValid() { + return fmt.Errorf("invalid AccessReviewEntryIncrementalTag value: %q", string(text)) + } + + *v = val + + return nil +} diff --git a/pkg/coredata/access_review_entry_incremental_tag_test.go b/pkg/coredata/access_review_entry_incremental_tag_test.go new file mode 100644 index 000000000..6a336ed7d --- /dev/null +++ b/pkg/coredata/access_review_entry_incremental_tag_test.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 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 "testing" + +func TestAccessReviewEntryIncrementalTagIsValid(t *testing.T) { + t.Parallel() + + for _, value := range AccessReviewEntryIncrementalTags() { + if !value.IsValid() { + t.Fatalf("IsValid() = false for %q", value) + } + } + + if AccessReviewEntryIncrementalTag("BOGUS").IsValid() { + t.Fatal("IsValid() = true for invalid value") + } +} + +func TestAccessReviewEntryIncrementalTagUnmarshalText(t *testing.T) { + t.Parallel() + + for _, value := range AccessReviewEntryIncrementalTags() { + t.Run(string(value), func(t *testing.T) { + t.Parallel() + + var got AccessReviewEntryIncrementalTag + if err := got.UnmarshalText([]byte(value)); err != nil { + t.Fatalf("UnmarshalText(%q) returned error: %v", value, err) + } + + if got != value { + t.Fatalf("UnmarshalText(%q) = %q, want %q", value, got, value) + } + }) + } + + t.Run("invalid", func(t *testing.T) { + t.Parallel() + + var got AccessReviewEntryIncrementalTag + if err := got.UnmarshalText([]byte("BOGUS")); err == nil { + t.Fatal("UnmarshalText(BOGUS) expected error") + } + }) +} + +func TestAccessReviewEntryIncrementalTagMarshalText(t *testing.T) { + t.Parallel() + + for _, value := range AccessReviewEntryIncrementalTags() { + t.Run(string(value), func(t *testing.T) { + t.Parallel() + + got, err := value.MarshalText() + if err != nil { + t.Fatalf("MarshalText() returned error: %v", err) + } + + if string(got) != value.String() { + t.Fatalf("MarshalText() = %q, want %q", string(got), value.String()) + } + }) + } +} diff --git a/pkg/coredata/access_entry_order_field.go b/pkg/coredata/access_review_entry_order_field.go similarity index 52% rename from pkg/coredata/access_entry_order_field.go rename to pkg/coredata/access_review_entry_order_field.go index 0bbdebcf1..3cad38253 100644 --- a/pkg/coredata/access_entry_order_field.go +++ b/pkg/coredata/access_review_entry_order_field.go @@ -22,48 +22,48 @@ import ( ) type ( - AccessEntryOrderField string + AccessReviewEntryOrderField string ) const ( - AccessEntryOrderFieldCreatedAt AccessEntryOrderField = "CREATED_AT" + AccessReviewEntryOrderFieldCreatedAt AccessReviewEntryOrderField = "CREATED_AT" ) var ( - _ page.OrderField = AccessEntryOrderField("") - _ fmt.Stringer = AccessEntryOrderField("") - _ encoding.TextMarshaler = AccessEntryOrderField("") - _ encoding.TextUnmarshaler = (*AccessEntryOrderField)(nil) + _ page.OrderField = AccessReviewEntryOrderField("") + _ fmt.Stringer = AccessReviewEntryOrderField("") + _ encoding.TextMarshaler = AccessReviewEntryOrderField("") + _ encoding.TextUnmarshaler = (*AccessReviewEntryOrderField)(nil) ) -func AccessEntryOrderFields() []AccessEntryOrderField { - return []AccessEntryOrderField{ - AccessEntryOrderFieldCreatedAt, +func AccessReviewEntryOrderFields() []AccessReviewEntryOrderField { + return []AccessReviewEntryOrderField{ + AccessReviewEntryOrderFieldCreatedAt, } } -func (v AccessEntryOrderField) IsValid() bool { +func (v AccessReviewEntryOrderField) IsValid() bool { switch v { case - AccessEntryOrderFieldCreatedAt: + AccessReviewEntryOrderFieldCreatedAt: return true } return false } -func (v AccessEntryOrderField) String() string { +func (v AccessReviewEntryOrderField) String() string { return string(v) } -func (v AccessEntryOrderField) MarshalText() ([]byte, error) { +func (v AccessReviewEntryOrderField) MarshalText() ([]byte, error) { return []byte(v.String()), nil } -func (v *AccessEntryOrderField) UnmarshalText(text []byte) error { - val := AccessEntryOrderField(text) +func (v *AccessReviewEntryOrderField) UnmarshalText(text []byte) error { + val := AccessReviewEntryOrderField(text) if !val.IsValid() { - return fmt.Errorf("invalid AccessEntryOrderField value: %q", string(text)) + return fmt.Errorf("invalid AccessReviewEntryOrderField value: %q", string(text)) } *v = val @@ -71,9 +71,9 @@ func (v *AccessEntryOrderField) UnmarshalText(text []byte) error { return nil } -func (p AccessEntryOrderField) Column() string { +func (p AccessReviewEntryOrderField) Column() string { switch p { - case AccessEntryOrderFieldCreatedAt: + case AccessReviewEntryOrderFieldCreatedAt: return "created_at" } diff --git a/pkg/coredata/access_review_entry_upsert_test.go b/pkg/coredata/access_review_entry_upsert_test.go new file mode 100644 index 000000000..c6cd0b360 --- /dev/null +++ b/pkg/coredata/access_review_entry_upsert_test.go @@ -0,0 +1,427 @@ +// Copyright (c) 2026 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_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/internal/test" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +// accessEntryFixture bootstraps the parent rows (organization, campaign, +// source) that the access_review_entries FKs require. +type accessEntryFixture struct { + scope *coredata.Scope + organizationID gid.GID + campaignID gid.GID + sourceID gid.GID + campaignSourceID gid.GID + accountKey string +} + +func seedAccessReviewEntryFixture(t *testing.T, ctx context.Context, client *pg.Client) accessEntryFixture { + t.Helper() + + tenantID := gid.NewTenantID() + scope := coredata.NewScope(tenantID) + organizationID := gid.New(tenantID, coredata.OrganizationEntityType) + campaignID := gid.New(tenantID, coredata.AccessReviewCampaignEntityType) + sourceID := gid.New(tenantID, coredata.AccessReviewSourceEntityType) + campaignSourceID := gid.New(tenantID, coredata.AccessReviewCampaignSourceEntityType) + accountKey := "upsert-freeze-test@example.com" + now := time.Now().UTC() + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + org := &coredata.Organization{ + ID: organizationID, + TenantID: tenantID, + Name: "Upsert Freeze Test Org", + CreatedAt: now, + UpdatedAt: now, + } + if err := org.Insert(ctx, tx); err != nil { + return err + } + + source := &coredata.AccessReviewSource{ + ID: sourceID, + OrganizationID: organizationID, + Name: "Upsert Freeze Test Source", + Category: coredata.AccessReviewSourceCategorySaaS, + CreatedAt: now, + UpdatedAt: now, + } + if err := source.Insert(ctx, tx, scope); err != nil { + return err + } + + campaign := &coredata.AccessReviewCampaign{ + ID: campaignID, + OrganizationID: organizationID, + Name: "Upsert Freeze Test Campaign", + Status: coredata.AccessReviewCampaignStatusDraft, + CreatedAt: now, + UpdatedAt: now, + } + if err := campaign.Insert(ctx, tx, scope); err != nil { + return err + } + + campaignSource := &coredata.AccessReviewCampaignSource{ + ID: campaignSourceID, + TenantID: tenantID, + AccessReviewCampaignID: campaignID, + AccessReviewSourceID: &sourceID, + Name: "Upsert Freeze Test Source", + Category: coredata.AccessReviewSourceCategorySaaS, + CreatedAt: now, + UpdatedAt: now, + } + if err := campaignSource.Upsert(ctx, tx, scope); err != nil { + return err + } + + return nil + })) + + t.Cleanup(func() { + _ = client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error { + // Delete access_review_entries first (no ON DELETE CASCADE for the org side), + // then parents. + if _, err := tx.Exec(ctx, `DELETE FROM access_review_entries WHERE access_review_campaign_id = $1`, campaignID); err != nil { + return err + } + + if _, err := tx.Exec(ctx, `DELETE FROM access_review_campaign_sources WHERE access_review_campaign_id = $1`, campaignID); err != nil { + return err + } + + if _, err := tx.Exec(ctx, `DELETE FROM access_review_campaigns WHERE id = $1`, campaignID); err != nil { + return err + } + + if _, err := tx.Exec(ctx, `DELETE FROM access_review_sources WHERE id = $1`, sourceID); err != nil { + return err + } + + if _, err := tx.Exec(ctx, `DELETE FROM organizations WHERE id = $1`, organizationID); err != nil { + return err + } + + return nil + }) + }) + + return accessEntryFixture{ + scope: scope, + organizationID: organizationID, + campaignID: campaignID, + sourceID: sourceID, + campaignSourceID: campaignSourceID, + accountKey: accountKey, + } +} + +func TestAccessReviewEntry_Upsert_FreezesDecidedFields(t *testing.T) { + t.Parallel() + + client := test.PGClient(t) + ctx := context.Background() + fx := seedAccessReviewEntryFixture(t, ctx, client) + + tenantID := fx.scope.GetTenantID() + originalFlagReasons := []string{"original-flag-reason"} + originalFlags := []coredata.AccessReviewEntryFlag{coredata.AccessReviewEntryFlagNew} + originalEmail := "old@example.com" + originalFullName := "Old Name" + originalRole := "viewer" + + t0 := time.Now().UTC().Truncate(time.Microsecond) + + // Step 1: Initial Upsert with PENDING decision. + entryID := gid.New(tenantID, coredata.AccessReviewEntryEntityType) + initial := &coredata.AccessReviewEntry{ + ID: entryID, + OrganizationID: fx.organizationID, + AccessReviewCampaignID: fx.campaignID, + AccessReviewCampaignSourceID: fx.campaignSourceID, + Email: originalEmail, + FullName: originalFullName, + Role: originalRole, + JobTitle: "", + IsAdmin: false, + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, + ExternalID: "ext-1", + AccountKey: fx.accountKey, + IncrementalTag: coredata.AccessReviewEntryIncrementalTagNew, + Flags: originalFlags, + FlagReasons: originalFlagReasons, + Decision: coredata.AccessReviewEntryDecisionPending, + DecisionNote: nil, + DecidedBy: nil, + DecidedAt: nil, + CreatedAt: t0, + UpdatedAt: t0, + } + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + return initial.Upsert(ctx, tx, fx.scope) + })) + + // Step 2: Record a decision via Update — APPROVED with decided_by / decided_at. + decisionTime := t0.Add(1 * time.Hour) + decidedBy := gid.New(tenantID, coredata.OrganizationEntityType) // opaque ID suffices: decided_by has no FK. + decisionNote := "looks good" + + decided := &coredata.AccessReviewEntry{ + ID: entryID, + Flags: originalFlags, + FlagReasons: originalFlagReasons, + Decision: coredata.AccessReviewEntryDecisionApproved, + DecisionNote: &decisionNote, + DecidedBy: &decidedBy, + DecidedAt: &decisionTime, + UpdatedAt: decisionTime, + } + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + return decided.Update(ctx, tx, fx.scope) + })) + + // Step 3: Second Upsert with the same unique key but new flags, new + // flag reasons, PENDING decision, nil note/decidedBy/decidedAt, and + // refreshed top-level fields (email, full_name, role). + t2 := decisionTime.Add(1 * time.Hour) + secondEmail := "new@example.com" + secondFullName := "New Name" + secondRole := "admin" + refresh := &coredata.AccessReviewEntry{ + ID: gid.New(tenantID, coredata.AccessReviewEntryEntityType), // ignored by ON CONFLICT + OrganizationID: fx.organizationID, + AccessReviewCampaignID: fx.campaignID, + AccessReviewCampaignSourceID: fx.campaignSourceID, + Email: secondEmail, + FullName: secondFullName, + Role: secondRole, + JobTitle: "", + IsAdmin: true, + MFAStatus: coredata.MFAStatusEnabled, + AuthMethod: coredata.AccessReviewEntryAuthMethodSSO, + AccountType: coredata.AccessReviewEntryAccountTypeUser, + ExternalID: "ext-1", + AccountKey: fx.accountKey, + IncrementalTag: coredata.AccessReviewEntryIncrementalTagUnchanged, + Flags: []coredata.AccessReviewEntryFlag{coredata.AccessReviewEntryFlagInactive}, + FlagReasons: []string{"refreshed-flag-reason"}, + Decision: coredata.AccessReviewEntryDecisionPending, + DecisionNote: nil, + DecidedBy: nil, + DecidedAt: nil, + CreatedAt: t2, + UpdatedAt: t2, + } + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + return refresh.Upsert(ctx, tx, fx.scope) + })) + + // Step 4: Load and assert the freeze semantics. + loaded := &coredata.AccessReviewEntry{} + + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return loaded.LoadByID(ctx, conn, fx.scope, entryID) + })) + + // Decision fields are FROZEN at APPROVED / decided_by / decided_at / + // decision_note from the Update call. + assert.Equal(t, coredata.AccessReviewEntryDecisionApproved, loaded.Decision, "decision must be frozen once locked") + require.NotNil(t, loaded.DecidedBy, "decided_by must be preserved") + assert.Equal(t, decidedBy, *loaded.DecidedBy) + require.NotNil(t, loaded.DecidedAt, "decided_at must be preserved") + assert.WithinDuration(t, decisionTime, *loaded.DecidedAt, time.Second) + require.NotNil(t, loaded.DecisionNote, "decision_note must be preserved") + assert.Equal(t, decisionNote, *loaded.DecisionNote) + + // Flags / flag_reasons are FROZEN (the new guard from Task 1): once a + // reviewer locks a decision, the evidence that drove that decision must + // not be silently replaced by a subsequent poll. + assert.Equal(t, originalFlags, loaded.Flags, "flags must be frozen once decision is locked") + assert.Equal(t, originalFlagReasons, loaded.FlagReasons, "flag_reasons must be frozen once decision is locked") + + // Columns that ARE refreshed on every poll. + assert.Equal(t, secondEmail, loaded.Email) + assert.Equal(t, secondFullName, loaded.FullName) + assert.Equal(t, secondRole, loaded.Role) + assert.True(t, loaded.IsAdmin) + assert.Equal(t, coredata.MFAStatusEnabled, loaded.MFAStatus) + assert.Equal(t, coredata.AccessReviewEntryAuthMethodSSO, loaded.AuthMethod) + assert.WithinDuration(t, t2, loaded.UpdatedAt, time.Second) +} + +// TestAccessReviewEntry_Upsert_RefreshesSourceTrackingFields pins the contract of +// the ON CONFLICT DO UPDATE SET clause: across repeated polls of the same +// (campaign, source, account_key), the columns that track live source state +// (email, full_name, role, is_admin, MFA, auth_method, last_login, etc.) +// move forward to the latest values, while the verdict-related columns +// (flags, flag_reasons, decision, decision_note, decided_by, decided_at) are +// never written by a re-poll -- those can only change through Update. +func TestAccessReviewEntry_Upsert_RefreshesSourceTrackingFields(t *testing.T) { + t.Parallel() + + client := test.PGClient(t) + ctx := context.Background() + fx := seedAccessReviewEntryFixture(t, ctx, client) + + tenantID := fx.scope.GetTenantID() + t0 := time.Now().UTC().Truncate(time.Microsecond) + + entryID := gid.New(tenantID, coredata.AccessReviewEntryEntityType) + first := &coredata.AccessReviewEntry{ + ID: entryID, + OrganizationID: fx.organizationID, + AccessReviewCampaignID: fx.campaignID, + AccessReviewCampaignSourceID: fx.campaignSourceID, + Email: "old@example.com", + FullName: "Old Name", + Role: "viewer", + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, + ExternalID: "ext-2", + AccountKey: fx.accountKey, + IncrementalTag: coredata.AccessReviewEntryIncrementalTagNew, + Flags: []coredata.AccessReviewEntryFlag{}, + FlagReasons: []string{}, + Decision: coredata.AccessReviewEntryDecisionPending, + CreatedAt: t0, + UpdatedAt: t0, + } + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + return first.Upsert(ctx, tx, fx.scope) + })) + + t1 := t0.Add(1 * time.Hour) + second := &coredata.AccessReviewEntry{ + ID: gid.New(tenantID, coredata.AccessReviewEntryEntityType), + OrganizationID: fx.organizationID, + AccessReviewCampaignID: fx.campaignID, + AccessReviewCampaignSourceID: fx.campaignSourceID, + Email: "new@example.com", + FullName: "New Name", + Role: "admin", + MFAStatus: coredata.MFAStatusEnabled, + AuthMethod: coredata.AccessReviewEntryAuthMethodSSO, + AccountType: coredata.AccessReviewEntryAccountTypeUser, + ExternalID: "ext-2", + AccountKey: fx.accountKey, + IncrementalTag: coredata.AccessReviewEntryIncrementalTagUnchanged, + Flags: []coredata.AccessReviewEntryFlag{}, + FlagReasons: []string{}, + Decision: coredata.AccessReviewEntryDecisionPending, + CreatedAt: t1, + UpdatedAt: t1, + } + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + return second.Upsert(ctx, tx, fx.scope) + })) + + loaded := &coredata.AccessReviewEntry{} + + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return loaded.LoadByID(ctx, conn, fx.scope, entryID) + })) + + // Source-tracking columns advanced to the second poll's values. + assert.Equal(t, "new@example.com", loaded.Email) + assert.Equal(t, "New Name", loaded.FullName) + assert.Equal(t, "admin", loaded.Role) + assert.Equal(t, coredata.MFAStatusEnabled, loaded.MFAStatus) + assert.Equal(t, coredata.AccessReviewEntryAuthMethodSSO, loaded.AuthMethod) + + // Verdict-related columns stayed at whatever the first Upsert set (empty / + // PENDING); the second Upsert did not touch them. + assert.Equal(t, coredata.AccessReviewEntryDecisionPending, loaded.Decision) + assert.Equal(t, []coredata.AccessReviewEntryFlag{}, loaded.Flags) + assert.Equal(t, []string{}, loaded.FlagReasons) + assert.Nil(t, loaded.DecisionNote) + assert.Nil(t, loaded.DecidedBy) + assert.Nil(t, loaded.DecidedAt) +} + +// TestAccessReviewEntry_Upsert_InsertsActiveAccount covers the shape FetchSource +// builds for an active account: a PENDING decision and explicit empty +// flags / flag_reasons slices. The access_review_entries.flags and flag_reasons +// columns are declared NOT NULL, so the caller (FetchSource) is responsible +// for passing non-nil slices. +func TestAccessReviewEntry_Upsert_InsertsActiveAccount(t *testing.T) { + t.Parallel() + + client := test.PGClient(t) + ctx := context.Background() + fx := seedAccessReviewEntryFixture(t, ctx, client) + + tenantID := fx.scope.GetTenantID() + t0 := time.Now().UTC().Truncate(time.Microsecond) + + entryID := gid.New(tenantID, coredata.AccessReviewEntryEntityType) + entry := &coredata.AccessReviewEntry{ + ID: entryID, + OrganizationID: fx.organizationID, + AccessReviewCampaignID: fx.campaignID, + AccessReviewCampaignSourceID: fx.campaignSourceID, + Email: "active@example.com", + FullName: "Active User", + Role: "member", + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, + ExternalID: "ext-active", + AccountKey: fx.accountKey, + IncrementalTag: coredata.AccessReviewEntryIncrementalTagNew, + Flags: []coredata.AccessReviewEntryFlag{}, + FlagReasons: []string{}, + Decision: coredata.AccessReviewEntryDecisionPending, + CreatedAt: t0, + UpdatedAt: t0, + } + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + return entry.Upsert(ctx, tx, fx.scope) + })) + + loaded := &coredata.AccessReviewEntry{} + + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return loaded.LoadByID(ctx, conn, fx.scope, entryID) + })) + + assert.Equal(t, coredata.AccessReviewEntryDecisionPending, loaded.Decision) + assert.Equal(t, []coredata.AccessReviewEntryFlag{}, loaded.Flags) + assert.Equal(t, []string{}, loaded.FlagReasons) + assert.Nil(t, loaded.DecisionNote) + assert.Nil(t, loaded.DecidedBy) + assert.Nil(t, loaded.DecidedAt) +} diff --git a/pkg/coredata/access_source.go b/pkg/coredata/access_review_source.go similarity index 69% rename from pkg/coredata/access_source.go rename to pkg/coredata/access_review_source.go index e25fa432f..2469e9e40 100644 --- a/pkg/coredata/access_source.go +++ b/pkg/coredata/access_review_source.go @@ -29,36 +29,36 @@ import ( ) type ( - AccessSource struct { - ID gid.GID `db:"id"` - OrganizationID gid.GID `db:"organization_id"` - ConnectorID *gid.GID `db:"connector_id"` - Name string `db:"name"` - Category AccessSourceCategory `db:"category"` - CsvData *string `db:"csv_data"` - NameSyncedAt *time.Time `db:"name_synced_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + AccessReviewSource struct { + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + ConnectorID *gid.GID `db:"connector_id"` + Name string `db:"name"` + Category AccessReviewSourceCategory `db:"category"` + CsvData *string `db:"csv_data"` + NameSyncedAt *time.Time `db:"name_synced_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } - AccessSources []*AccessSource + AccessReviewSources []*AccessReviewSource ) -func (as AccessSource) CursorKey(orderBy AccessSourceOrderField) page.CursorKey { +func (as AccessReviewSource) CursorKey(orderBy AccessReviewSourceOrderField) page.CursorKey { switch orderBy { - case AccessSourceOrderFieldCreatedAt: + case AccessReviewSourceOrderFieldCreatedAt: return page.NewCursorKey(as.ID, as.CreatedAt) } panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (as *AccessSource) AuthorizationAttributes( +func (as *AccessReviewSource) AuthorizationAttributes( ctx context.Context, conn pg.Querier, resourceIDs []gid.GID, ) (policy.AttributesByID, error) { - q := `SELECT id, organization_id FROM access_sources WHERE id = ANY(@resource_ids::text[])` + q := `SELECT id, organization_id FROM access_review_sources WHERE id = ANY(@resource_ids::text[])` args := pgx.StrictNamedArgs{ "resource_ids": resourceIDs, @@ -92,7 +92,7 @@ func (as *AccessSource) AuthorizationAttributes( return attrsByID, nil } -func (as *AccessSource) LoadByID( +func (as *AccessReviewSource) LoadByID( ctx context.Context, conn pg.Querier, scope Scoper, @@ -110,7 +110,7 @@ SELECT created_at, updated_at FROM - access_sources + access_review_sources WHERE %s AND id = @id @@ -123,10 +123,10 @@ LIMIT 1; rows, err := conn.Query(ctx, q, args) if err != nil { - return fmt.Errorf("cannot query access_sources: %w", err) + return fmt.Errorf("cannot query access_review_sources: %w", err) } - source, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessSource]) + source, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessReviewSource]) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return ErrResourceNotFound @@ -140,14 +140,14 @@ LIMIT 1; return nil } -func (as *AccessSource) Insert( +func (as *AccessReviewSource) Insert( ctx context.Context, conn pg.Tx, scope Scoper, ) error { q := ` INSERT INTO - access_sources ( + access_review_sources ( id, tenant_id, organization_id, @@ -194,13 +194,13 @@ VALUES ( return nil } -func (as *AccessSource) Update( +func (as *AccessReviewSource) Update( ctx context.Context, conn pg.Tx, scope Scoper, ) error { q := ` -UPDATE access_sources +UPDATE access_review_sources SET name = @name, category = @category, @@ -237,13 +237,13 @@ WHERE return nil } -func (as *AccessSource) Delete( +func (as *AccessReviewSource) Delete( ctx context.Context, conn pg.Tx, scope Scoper, ) error { q := ` -DELETE FROM access_sources +DELETE FROM access_review_sources WHERE %s AND id = @id ` q = fmt.Sprintf(q, scope.SQLFragment()) @@ -263,12 +263,12 @@ WHERE %s AND id = @id return nil } -func (sources *AccessSources) LoadByOrganizationID( +func (sources *AccessReviewSources) LoadByOrganizationID( ctx context.Context, conn pg.Querier, scope Scoper, organizationID gid.GID, - cursor *page.Cursor[AccessSourceOrderField], + cursor *page.Cursor[AccessReviewSourceOrderField], ) error { q := ` SELECT @@ -282,7 +282,7 @@ SELECT created_at, updated_at FROM - access_sources + access_review_sources WHERE %s AND organization_id = @organization_id @@ -296,12 +296,12 @@ WHERE rows, err := conn.Query(ctx, q, args) if err != nil { - return fmt.Errorf("cannot query access_sources: %w", err) + return fmt.Errorf("cannot query access_review_sources: %w", err) } - result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessSource]) + result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewSource]) if err != nil { - return fmt.Errorf("cannot collect access_sources: %w", err) + return fmt.Errorf("cannot collect access_review_sources: %w", err) } *sources = result @@ -309,7 +309,7 @@ WHERE return nil } -func (sources *AccessSources) CountByOrganizationID( +func (sources *AccessReviewSources) CountByOrganizationID( ctx context.Context, conn pg.Querier, scope Scoper, @@ -317,7 +317,7 @@ func (sources *AccessSources) CountByOrganizationID( ) (int, error) { q := ` SELECT COUNT(id) -FROM access_sources +FROM access_review_sources WHERE %s AND organization_id = @organization_id; @@ -329,13 +329,13 @@ WHERE var count int if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { - return 0, fmt.Errorf("cannot count access_sources: %w", err) + return 0, fmt.Errorf("cannot count access_review_sources: %w", err) } return count, nil } -func (sources *AccessSources) CountByConnectorID( +func (sources *AccessReviewSources) CountByConnectorID( ctx context.Context, conn pg.Querier, scope Scoper, @@ -343,7 +343,7 @@ func (sources *AccessSources) CountByConnectorID( ) (int, error) { q := ` SELECT COUNT(id) -FROM access_sources +FROM access_review_sources WHERE %s AND connector_id = @connector_id; @@ -355,70 +355,20 @@ WHERE var count int if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil { - return 0, fmt.Errorf("cannot count access_sources by connector ID: %w", err) + return 0, fmt.Errorf("cannot count access_review_sources by connector ID: %w", err) } return count, nil } -// LoadScopeSourcesByCampaignID loads the campaign scope sources in deterministic -// name order. Only explicitly scoped sources are returned. -func (sources *AccessSources) LoadScopeSourcesByCampaignID( - ctx context.Context, - conn pg.Querier, - scope Scoper, - campaignID gid.GID, -) error { - q := ` -SELECT - id, - organization_id, - connector_id, - name, - category, - csv_data, - name_synced_at, - created_at, - updated_at -FROM - access_sources -WHERE - %s - AND id IN ( - SELECT arcss.access_source_id - FROM access_review_campaign_scope_systems arcss - WHERE arcss.access_review_campaign_id = @campaign_id - ) -ORDER BY name ASC -` - q = fmt.Sprintf(q, scope.SQLFragment()) - - args := pgx.StrictNamedArgs{"campaign_id": campaignID} - maps.Copy(args, scope.SQLArguments()) - - rows, err := conn.Query(ctx, q, args) - if err != nil { - return fmt.Errorf("cannot query scope access_sources: %w", err) - } - - result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessSource]) - if err != nil { - return fmt.Errorf("cannot collect scope access_sources: %w", err) - } - - *sources = result - - return nil -} - -// ErrNoAccessSourceNameSyncAvailable is returned when no access source +// ErrNoAccessReviewSourceNameSyncAvailable is returned when no access source // needs its name synced from its connector. -var ErrNoAccessSourceNameSyncAvailable = fmt.Errorf("no access source name sync available") +var ErrNoAccessReviewSourceNameSyncAvailable = fmt.Errorf("no access source name sync available") // LoadNextUnsyncedNameForUpdateSkipLocked claims the next access source that // has a connector but has not yet had its name synced. The row is locked with // FOR UPDATE SKIP LOCKED so concurrent workers do not pick the same row. -func (as *AccessSource) LoadNextUnsyncedNameForUpdateSkipLocked( +func (as *AccessReviewSource) LoadNextUnsyncedNameForUpdateSkipLocked( ctx context.Context, conn pg.Tx, ) error { @@ -434,7 +384,7 @@ SELECT created_at, updated_at FROM - access_sources + access_review_sources WHERE connector_id IS NOT NULL AND name_synced_at IS NULL @@ -446,13 +396,13 @@ FOR UPDATE SKIP LOCKED; rows, err := conn.Query(ctx, q) if err != nil { - return fmt.Errorf("cannot query unsynced access_sources: %w", err) + return fmt.Errorf("cannot query unsynced access_review_sources: %w", err) } - row, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessSource]) + row, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessReviewSource]) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return ErrNoAccessSourceNameSyncAvailable + return ErrNoAccessReviewSourceNameSyncAvailable } return fmt.Errorf("cannot collect unsynced access source: %w", err) diff --git a/pkg/coredata/access_review_source_category.go b/pkg/coredata/access_review_source_category.go new file mode 100644 index 000000000..78b4a4537 --- /dev/null +++ b/pkg/coredata/access_review_source_category.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 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 ( + "encoding" + "fmt" +) + +type AccessReviewSourceCategory string + +const ( + AccessReviewSourceCategorySaaS AccessReviewSourceCategory = "SAAS" + AccessReviewSourceCategoryCloudInfra AccessReviewSourceCategory = "CLOUD_INFRA" + AccessReviewSourceCategorySourceCode AccessReviewSourceCategory = "SOURCE_CODE" + AccessReviewSourceCategoryOther AccessReviewSourceCategory = "OTHER" +) + +var ( + _ fmt.Stringer = AccessReviewSourceCategory("") + _ encoding.TextMarshaler = AccessReviewSourceCategory("") + _ encoding.TextUnmarshaler = (*AccessReviewSourceCategory)(nil) +) + +func AccessReviewSourceCategories() []AccessReviewSourceCategory { + return []AccessReviewSourceCategory{ + AccessReviewSourceCategorySaaS, + AccessReviewSourceCategoryCloudInfra, + AccessReviewSourceCategorySourceCode, + AccessReviewSourceCategoryOther, + } +} + +func (v AccessReviewSourceCategory) IsValid() bool { + switch v { + case + AccessReviewSourceCategorySaaS, + AccessReviewSourceCategoryCloudInfra, + AccessReviewSourceCategorySourceCode, + AccessReviewSourceCategoryOther: + return true + } + + return false +} + +func (v AccessReviewSourceCategory) String() string { + return string(v) +} + +func (v AccessReviewSourceCategory) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *AccessReviewSourceCategory) UnmarshalText(text []byte) error { + val := AccessReviewSourceCategory(text) + if !val.IsValid() { + return fmt.Errorf("invalid AccessReviewSourceCategory value: %q", string(text)) + } + + *v = val + + return nil +} diff --git a/pkg/coredata/access_entry_incremental_tag_test.go b/pkg/coredata/access_review_source_category_test.go similarity index 78% rename from pkg/coredata/access_entry_incremental_tag_test.go rename to pkg/coredata/access_review_source_category_test.go index b06f5a190..cdcceb0a7 100644 --- a/pkg/coredata/access_entry_incremental_tag_test.go +++ b/pkg/coredata/access_review_source_category_test.go @@ -16,28 +16,28 @@ package coredata import "testing" -func TestAccessEntryIncrementalTagIsValid(t *testing.T) { +func TestAccessReviewSourceCategoryIsValid(t *testing.T) { t.Parallel() - for _, value := range AccessEntryIncrementalTags() { + for _, value := range AccessReviewSourceCategories() { if !value.IsValid() { t.Fatalf("IsValid() = false for %q", value) } } - if AccessEntryIncrementalTag("BOGUS").IsValid() { + if AccessReviewSourceCategory("BOGUS").IsValid() { t.Fatal("IsValid() = true for invalid value") } } -func TestAccessEntryIncrementalTagUnmarshalText(t *testing.T) { +func TestAccessReviewSourceCategoryUnmarshalText(t *testing.T) { t.Parallel() - for _, value := range AccessEntryIncrementalTags() { + for _, value := range AccessReviewSourceCategories() { t.Run(string(value), func(t *testing.T) { t.Parallel() - var got AccessEntryIncrementalTag + var got AccessReviewSourceCategory if err := got.UnmarshalText([]byte(value)); err != nil { t.Fatalf("UnmarshalText(%q) returned error: %v", value, err) } @@ -51,17 +51,17 @@ func TestAccessEntryIncrementalTagUnmarshalText(t *testing.T) { t.Run("invalid", func(t *testing.T) { t.Parallel() - var got AccessEntryIncrementalTag + var got AccessReviewSourceCategory if err := got.UnmarshalText([]byte("BOGUS")); err == nil { t.Fatal("UnmarshalText(BOGUS) expected error") } }) } -func TestAccessEntryIncrementalTagMarshalText(t *testing.T) { +func TestAccessReviewSourceCategoryMarshalText(t *testing.T) { t.Parallel() - for _, value := range AccessEntryIncrementalTags() { + for _, value := range AccessReviewSourceCategories() { t.Run(string(value), func(t *testing.T) { t.Parallel() diff --git a/pkg/coredata/access_source_order_field.go b/pkg/coredata/access_review_source_order_field.go similarity index 52% rename from pkg/coredata/access_source_order_field.go rename to pkg/coredata/access_review_source_order_field.go index 476e3ddc6..91b54da1d 100644 --- a/pkg/coredata/access_source_order_field.go +++ b/pkg/coredata/access_review_source_order_field.go @@ -22,48 +22,48 @@ import ( ) type ( - AccessSourceOrderField string + AccessReviewSourceOrderField string ) const ( - AccessSourceOrderFieldCreatedAt AccessSourceOrderField = "CREATED_AT" + AccessReviewSourceOrderFieldCreatedAt AccessReviewSourceOrderField = "CREATED_AT" ) var ( - _ page.OrderField = AccessSourceOrderField("") - _ fmt.Stringer = AccessSourceOrderField("") - _ encoding.TextMarshaler = AccessSourceOrderField("") - _ encoding.TextUnmarshaler = (*AccessSourceOrderField)(nil) + _ page.OrderField = AccessReviewSourceOrderField("") + _ fmt.Stringer = AccessReviewSourceOrderField("") + _ encoding.TextMarshaler = AccessReviewSourceOrderField("") + _ encoding.TextUnmarshaler = (*AccessReviewSourceOrderField)(nil) ) -func AccessSourceOrderFields() []AccessSourceOrderField { - return []AccessSourceOrderField{ - AccessSourceOrderFieldCreatedAt, +func AccessReviewSourceOrderFields() []AccessReviewSourceOrderField { + return []AccessReviewSourceOrderField{ + AccessReviewSourceOrderFieldCreatedAt, } } -func (v AccessSourceOrderField) IsValid() bool { +func (v AccessReviewSourceOrderField) IsValid() bool { switch v { case - AccessSourceOrderFieldCreatedAt: + AccessReviewSourceOrderFieldCreatedAt: return true } return false } -func (v AccessSourceOrderField) String() string { +func (v AccessReviewSourceOrderField) String() string { return string(v) } -func (v AccessSourceOrderField) MarshalText() ([]byte, error) { +func (v AccessReviewSourceOrderField) MarshalText() ([]byte, error) { return []byte(v.String()), nil } -func (v *AccessSourceOrderField) UnmarshalText(text []byte) error { - val := AccessSourceOrderField(text) +func (v *AccessReviewSourceOrderField) UnmarshalText(text []byte) error { + val := AccessReviewSourceOrderField(text) if !val.IsValid() { - return fmt.Errorf("invalid AccessSourceOrderField value: %q", string(text)) + return fmt.Errorf("invalid AccessReviewSourceOrderField value: %q", string(text)) } *v = val @@ -71,9 +71,9 @@ func (v *AccessSourceOrderField) UnmarshalText(text []byte) error { return nil } -func (p AccessSourceOrderField) Column() string { +func (p AccessReviewSourceOrderField) Column() string { switch p { - case AccessSourceOrderFieldCreatedAt: + case AccessReviewSourceOrderFieldCreatedAt: return "created_at" } diff --git a/pkg/coredata/access_entry_statistics.go b/pkg/coredata/access_review_statistics.go similarity index 80% rename from pkg/coredata/access_entry_statistics.go rename to pkg/coredata/access_review_statistics.go index b69e91ab8..bf3921a0a 100644 --- a/pkg/coredata/access_entry_statistics.go +++ b/pkg/coredata/access_review_statistics.go @@ -24,14 +24,14 @@ import ( "go.probo.inc/probo/pkg/gid" ) -type AccessEntryStatistics struct { +type AccessReviewStatistics struct { TotalCount int - DecisionCounts map[AccessEntryDecision]int - FlagCounts map[AccessEntryFlag]int - IncrementalTagCounts map[AccessEntryIncrementalTag]int + DecisionCounts map[AccessReviewEntryDecision]int + FlagCounts map[AccessReviewEntryFlag]int + IncrementalTagCounts map[AccessReviewEntryIncrementalTag]int } -func (s *AccessEntryStatistics) LoadByCampaignID( +func (s *AccessReviewStatistics) LoadByCampaignID( ctx context.Context, conn pg.Querier, scope Scoper, @@ -40,14 +40,14 @@ func (s *AccessEntryStatistics) LoadByCampaignID( args := pgx.StrictNamedArgs{"campaign_id": campaignID} maps.Copy(args, scope.SQLArguments()) - s.DecisionCounts = make(map[AccessEntryDecision]int) - s.FlagCounts = make(map[AccessEntryFlag]int) - s.IncrementalTagCounts = make(map[AccessEntryIncrementalTag]int) + s.DecisionCounts = make(map[AccessReviewEntryDecision]int) + s.FlagCounts = make(map[AccessReviewEntryFlag]int) + s.IncrementalTagCounts = make(map[AccessReviewEntryIncrementalTag]int) s.TotalCount = 0 q := ` SELECT decision, COUNT(*) as count -FROM access_entries +FROM access_review_entries WHERE %s AND access_review_campaign_id = @campaign_id @@ -63,7 +63,7 @@ GROUP BY decision; for rows.Next() { var ( - decision AccessEntryDecision + decision AccessReviewEntryDecision count int ) @@ -81,7 +81,7 @@ GROUP BY decision; q = ` SELECT f, COUNT(*) as count -FROM access_entries, unnest(flags) AS f +FROM access_review_entries, unnest(flags) AS f WHERE %s AND access_review_campaign_id = @campaign_id @@ -97,7 +97,7 @@ GROUP BY f; for rows.Next() { var ( - flag AccessEntryFlag + flag AccessReviewEntryFlag count int ) @@ -114,7 +114,7 @@ GROUP BY f; q = ` SELECT incremental_tag, COUNT(*) as count -FROM access_entries +FROM access_review_entries WHERE %s AND access_review_campaign_id = @campaign_id @@ -130,7 +130,7 @@ GROUP BY incremental_tag; for rows.Next() { var ( - tag AccessEntryIncrementalTag + tag AccessReviewEntryIncrementalTag count int ) @@ -148,7 +148,7 @@ GROUP BY incremental_tag; return nil } -func (s *AccessEntryStatistics) LoadByCampaignIDAndSourceID( +func (s *AccessReviewStatistics) LoadByCampaignIDAndSourceID( ctx context.Context, conn pg.Querier, scope Scoper, @@ -161,18 +161,18 @@ func (s *AccessEntryStatistics) LoadByCampaignIDAndSourceID( } maps.Copy(args, scope.SQLArguments()) - s.DecisionCounts = make(map[AccessEntryDecision]int) - s.FlagCounts = make(map[AccessEntryFlag]int) - s.IncrementalTagCounts = make(map[AccessEntryIncrementalTag]int) + s.DecisionCounts = make(map[AccessReviewEntryDecision]int) + s.FlagCounts = make(map[AccessReviewEntryFlag]int) + s.IncrementalTagCounts = make(map[AccessReviewEntryIncrementalTag]int) s.TotalCount = 0 q := ` SELECT decision, COUNT(*) as count -FROM access_entries +FROM access_review_entries WHERE %s AND access_review_campaign_id = @campaign_id - AND access_source_id = @source_id + AND access_review_campaign_source_id = @source_id GROUP BY decision; ` q = fmt.Sprintf(q, scope.SQLFragment()) @@ -185,7 +185,7 @@ GROUP BY decision; for rows.Next() { var ( - decision AccessEntryDecision + decision AccessReviewEntryDecision count int ) @@ -203,11 +203,11 @@ GROUP BY decision; q = ` SELECT f, COUNT(*) as count -FROM access_entries, unnest(flags) AS f +FROM access_review_entries, unnest(flags) AS f WHERE %s AND access_review_campaign_id = @campaign_id - AND access_source_id = @source_id + AND access_review_campaign_source_id = @source_id GROUP BY f; ` q = fmt.Sprintf(q, scope.SQLFragment()) @@ -220,7 +220,7 @@ GROUP BY f; for rows.Next() { var ( - flag AccessEntryFlag + flag AccessReviewEntryFlag count int ) @@ -237,11 +237,11 @@ GROUP BY f; q = ` SELECT incremental_tag, COUNT(*) as count -FROM access_entries +FROM access_review_entries WHERE %s AND access_review_campaign_id = @campaign_id - AND access_source_id = @source_id + AND access_review_campaign_source_id = @source_id GROUP BY incremental_tag; ` q = fmt.Sprintf(q, scope.SQLFragment()) @@ -254,7 +254,7 @@ GROUP BY incremental_tag; for rows.Next() { var ( - tag AccessEntryIncrementalTag + tag AccessReviewEntryIncrementalTag count int ) diff --git a/pkg/coredata/access_source_category.go b/pkg/coredata/access_source_category.go deleted file mode 100644 index a6bfffe95..000000000 --- a/pkg/coredata/access_source_category.go +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) 2026 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 ( - "encoding" - "fmt" -) - -type AccessSourceCategory string - -const ( - AccessSourceCategorySaaS AccessSourceCategory = "SAAS" - AccessSourceCategoryCloudInfra AccessSourceCategory = "CLOUD_INFRA" - AccessSourceCategorySourceCode AccessSourceCategory = "SOURCE_CODE" - AccessSourceCategoryOther AccessSourceCategory = "OTHER" -) - -var ( - _ fmt.Stringer = AccessSourceCategory("") - _ encoding.TextMarshaler = AccessSourceCategory("") - _ encoding.TextUnmarshaler = (*AccessSourceCategory)(nil) -) - -func AccessSourceCategories() []AccessSourceCategory { - return []AccessSourceCategory{ - AccessSourceCategorySaaS, - AccessSourceCategoryCloudInfra, - AccessSourceCategorySourceCode, - AccessSourceCategoryOther, - } -} - -func (v AccessSourceCategory) IsValid() bool { - switch v { - case - AccessSourceCategorySaaS, - AccessSourceCategoryCloudInfra, - AccessSourceCategorySourceCode, - AccessSourceCategoryOther: - return true - } - - return false -} - -func (v AccessSourceCategory) String() string { - return string(v) -} - -func (v AccessSourceCategory) MarshalText() ([]byte, error) { - return []byte(v.String()), nil -} - -func (v *AccessSourceCategory) UnmarshalText(text []byte) error { - val := AccessSourceCategory(text) - if !val.IsValid() { - return fmt.Errorf("invalid AccessSourceCategory value: %q", string(text)) - } - - *v = val - - return nil -} diff --git a/pkg/coredata/access_source_category_test.go b/pkg/coredata/access_source_category_test.go deleted file mode 100644 index dfc1aac83..000000000 --- a/pkg/coredata/access_source_category_test.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) 2026 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 "testing" - -func TestAccessSourceCategoryIsValid(t *testing.T) { - t.Parallel() - - for _, value := range AccessSourceCategories() { - if !value.IsValid() { - t.Fatalf("IsValid() = false for %q", value) - } - } - - if AccessSourceCategory("BOGUS").IsValid() { - t.Fatal("IsValid() = true for invalid value") - } -} - -func TestAccessSourceCategoryUnmarshalText(t *testing.T) { - t.Parallel() - - for _, value := range AccessSourceCategories() { - t.Run(string(value), func(t *testing.T) { - t.Parallel() - - var got AccessSourceCategory - if err := got.UnmarshalText([]byte(value)); err != nil { - t.Fatalf("UnmarshalText(%q) returned error: %v", value, err) - } - - if got != value { - t.Fatalf("UnmarshalText(%q) = %q, want %q", value, got, value) - } - }) - } - - t.Run("invalid", func(t *testing.T) { - t.Parallel() - - var got AccessSourceCategory - if err := got.UnmarshalText([]byte("BOGUS")); err == nil { - t.Fatal("UnmarshalText(BOGUS) expected error") - } - }) -} - -func TestAccessSourceCategoryMarshalText(t *testing.T) { - t.Parallel() - - for _, value := range AccessSourceCategories() { - t.Run(string(value), func(t *testing.T) { - t.Parallel() - - got, err := value.MarshalText() - if err != nil { - t.Fatalf("MarshalText() returned error: %v", err) - } - - if string(got) != value.String() { - t.Fatalf("MarshalText() = %q, want %q", string(got), value.String()) - } - }) - } -} diff --git a/pkg/coredata/auth_method.go b/pkg/coredata/auth_method.go index d6cd7e179..916358db7 100644 --- a/pkg/coredata/auth_method.go +++ b/pkg/coredata/auth_method.go @@ -19,58 +19,58 @@ import ( "fmt" ) -type AccessEntryAuthMethod string +type AccessReviewEntryAuthMethod string const ( - AccessEntryAuthMethodSSO AccessEntryAuthMethod = "SSO" - AccessEntryAuthMethodPassword AccessEntryAuthMethod = "PASSWORD" - AccessEntryAuthMethodAPIKey AccessEntryAuthMethod = "API_KEY" - AccessEntryAuthMethodServiceAccount AccessEntryAuthMethod = "SERVICE_ACCOUNT" - AccessEntryAuthMethodUnknown AccessEntryAuthMethod = "UNKNOWN" + AccessReviewEntryAuthMethodSSO AccessReviewEntryAuthMethod = "SSO" + AccessReviewEntryAuthMethodPassword AccessReviewEntryAuthMethod = "PASSWORD" + AccessReviewEntryAuthMethodAPIKey AccessReviewEntryAuthMethod = "API_KEY" + AccessReviewEntryAuthMethodServiceAccount AccessReviewEntryAuthMethod = "SERVICE_ACCOUNT" + AccessReviewEntryAuthMethodUnknown AccessReviewEntryAuthMethod = "UNKNOWN" ) var ( - _ fmt.Stringer = AccessEntryAuthMethod("") - _ encoding.TextMarshaler = AccessEntryAuthMethod("") - _ encoding.TextUnmarshaler = (*AccessEntryAuthMethod)(nil) + _ fmt.Stringer = AccessReviewEntryAuthMethod("") + _ encoding.TextMarshaler = AccessReviewEntryAuthMethod("") + _ encoding.TextUnmarshaler = (*AccessReviewEntryAuthMethod)(nil) ) -func AccessEntryAuthMethods() []AccessEntryAuthMethod { - return []AccessEntryAuthMethod{ - AccessEntryAuthMethodSSO, - AccessEntryAuthMethodPassword, - AccessEntryAuthMethodAPIKey, - AccessEntryAuthMethodServiceAccount, - AccessEntryAuthMethodUnknown, +func AccessReviewEntryAuthMethods() []AccessReviewEntryAuthMethod { + return []AccessReviewEntryAuthMethod{ + AccessReviewEntryAuthMethodSSO, + AccessReviewEntryAuthMethodPassword, + AccessReviewEntryAuthMethodAPIKey, + AccessReviewEntryAuthMethodServiceAccount, + AccessReviewEntryAuthMethodUnknown, } } -func (v AccessEntryAuthMethod) IsValid() bool { +func (v AccessReviewEntryAuthMethod) IsValid() bool { switch v { case - AccessEntryAuthMethodSSO, - AccessEntryAuthMethodPassword, - AccessEntryAuthMethodAPIKey, - AccessEntryAuthMethodServiceAccount, - AccessEntryAuthMethodUnknown: + AccessReviewEntryAuthMethodSSO, + AccessReviewEntryAuthMethodPassword, + AccessReviewEntryAuthMethodAPIKey, + AccessReviewEntryAuthMethodServiceAccount, + AccessReviewEntryAuthMethodUnknown: return true } return false } -func (v AccessEntryAuthMethod) String() string { +func (v AccessReviewEntryAuthMethod) String() string { return string(v) } -func (v AccessEntryAuthMethod) MarshalText() ([]byte, error) { +func (v AccessReviewEntryAuthMethod) MarshalText() ([]byte, error) { return []byte(v.String()), nil } -func (v *AccessEntryAuthMethod) UnmarshalText(text []byte) error { - val := AccessEntryAuthMethod(text) +func (v *AccessReviewEntryAuthMethod) UnmarshalText(text []byte) error { + val := AccessReviewEntryAuthMethod(text) if !val.IsValid() { - return fmt.Errorf("invalid AccessEntryAuthMethod value: %q", string(text)) + return fmt.Errorf("invalid AccessReviewEntryAuthMethod value: %q", string(text)) } *v = val diff --git a/pkg/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index b6fe32dc0..9fb123be3 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -109,7 +109,7 @@ type ( // DatadogConnectorSettings holds the per-customer Datadog site captured // during the OAuth callback. Region is the site key (e.g. "US3") used for - // the AccessSource title; Domain is the API domain (e.g. + // the AccessReviewSource title; Domain is the API domain (e.g. // "us3.datadoghq.com") the driver and name resolver use to build hosts. DatadogConnectorSettings struct { Region string `json:"region"` @@ -132,7 +132,7 @@ type ( // redirect, and it rides the signed state token to the callback — // Zendesk does not echo it back). Subdomain is the part of // .zendesk.com, used by the driver to build the API host and - // by the name resolver for the AccessSource title. + // by the name resolver for the AccessReviewSource title. ZendeskConnectorSettings struct { Subdomain string `json:"subdomain"` } diff --git a/pkg/coredata/entity_type_reg.go b/pkg/coredata/entity_type_reg.go index 1f28e4af2..e93b9b23c 100644 --- a/pkg/coredata/entity_type_reg.go +++ b/pkg/coredata/entity_type_reg.go @@ -23,108 +23,110 @@ var ( ) const ( - OrganizationEntityType uint16 = 0 - FrameworkEntityType uint16 = 1 - MeasureEntityType uint16 = 2 - TaskEntityType uint16 = 3 - EvidenceEntityType uint16 = 4 - ConnectorEntityType uint16 = 5 - ThirdPartyRiskAssessmentEntityType uint16 = 6 - ThirdPartyEntityType uint16 = 7 - _ uint16 = 8 // PeopleEntityType - removed - ThirdPartyComplianceReportEntityType uint16 = 9 - DocumentEntityType uint16 = 10 - IdentityEntityType uint16 = 11 - SessionEntityType uint16 = 12 - EmailEntityType uint16 = 13 - ControlEntityType uint16 = 14 - RiskEntityType uint16 = 15 - DocumentVersionEntityType uint16 = 16 - DocumentVersionSignatureEntityType uint16 = 17 - AssetEntityType uint16 = 18 - DatumEntityType uint16 = 19 - AuditEntityType uint16 = 20 - _ uint16 = 21 // ReportEntityType - removed - TrustCenterEntityType uint16 = 22 - TrustCenterAccessEntityType uint16 = 23 - ThirdPartyBusinessAssociateAgreementEntityType uint16 = 24 - FileEntityType uint16 = 25 - ThirdPartyContactEntityType uint16 = 26 - ThirdPartyDataPrivacyAgreementEntityType uint16 = 27 - _ uint16 = 28 // NonconformityEntityType - removed - ObligationEntityType uint16 = 29 - ThirdPartyServiceEntityType uint16 = 30 - _ uint16 = 31 // SnapshotEntityType - removed - _ uint16 = 32 // ContinualImprovementEntityType - removed - ProcessingActivityEntityType uint16 = 33 - ExportJobEntityType uint16 = 34 - TrustCenterReferenceEntityType uint16 = 35 - TrustCenterDocumentAccessEntityType uint16 = 36 - CustomDomainEntityType uint16 = 37 - InvitationEntityType uint16 = 38 - MembershipEntityType uint16 = 39 - SlackMessageEntityType uint16 = 40 - TrustCenterFileEntityType uint16 = 41 - SAMLConfigurationEntityType uint16 = 42 - PersonalAPIKeyEntityType uint16 = 43 - _ uint16 = 44 // PersonalAPIKeyMembershipEntityType - removed - _ uint16 = 45 // MeetingEntityType - removed - DataProtectionImpactAssessmentEntityType uint16 = 46 - TransferImpactAssessmentEntityType uint16 = 47 - RightsRequestEntityType uint16 = 48 - StatementOfApplicabilityEntityType uint16 = 49 - ApplicabilityStatementEntityType uint16 = 50 - MembershipProfileEntityType uint16 = 51 - SCIMConfigurationEntityType uint16 = 52 - SCIMEventEntityType uint16 = 53 - TokenEntityType uint16 = 54 - SCIMBridgeEntityType uint16 = 55 - WebhookSubscriptionEntityType uint16 = 56 - WebhookDataEntityType uint16 = 57 - WebhookEventEntityType uint16 = 58 - ElectronicSignatureEntityType uint16 = 59 - ElectronicSignatureEventEntityType uint16 = 60 - EmailAttachmentEntityType uint16 = 61 - ComplianceFrameworkEntityType uint16 = 62 - ComplianceExternalURLEntityType uint16 = 63 - MailingListEntityType uint16 = 64 - MailingListSubscriberEntityType uint16 = 65 - MailingListUpdateEntityType uint16 = 66 - FindingEntityType uint16 = 67 - AuditLogEntryEntityType uint16 = 68 - DocumentVersionApprovalQuorumEntityType uint16 = 69 - DocumentVersionApprovalDecisionEntityType uint16 = 70 - AccessSourceEntityType uint16 = 71 - AccessReviewCampaignEntityType uint16 = 72 - AccessEntryEntityType uint16 = 73 - AccessEntryDecisionHistoryEntityType uint16 = 74 - CookieBannerEntityType uint16 = 75 - CookieCategoryEntityType uint16 = 76 - CookieConsentRecordEntityType uint16 = 77 - CookieBannerVersionEntityType uint16 = 78 - OAuth2ClientEntityType uint16 = 79 - OAuth2ConsentEntityType uint16 = 80 - OAuth2AccessTokenEntityType uint16 = 81 - OAuth2RefreshTokenEntityType uint16 = 82 - OAuth2AuthorizationCodeEntityType uint16 = 83 - OAuth2DeviceCodeEntityType uint16 = 84 - _ uint16 = 85 // CookieEntityType - removed - CookieBannerTranslationEntityType uint16 = 86 - AgentRunEntityType uint16 = 87 - _ uint16 = 88 // CookiePatternEntityType - removed - TrackerPatternEntityType uint16 = 89 - DetectedTrackerEntityType uint16 = 90 - TrackerResourceEntityType uint16 = 91 - CommonThirdPartyEntityType uint16 = 92 - CommonThirdPartyDomainEntityType uint16 = 93 - CommonTrackerPatternEntityType uint16 = 94 - RiskAssessmentEntityType uint16 = 95 - RiskAssessmentNodeEntityType uint16 = 96 - RiskAssessmentProcessEntityType uint16 = 97 - RiskAssessmentThreatEntityType uint16 = 98 - RiskAssessmentScopeEntityType uint16 = 99 - RiskAssessmentScenarioEntityType uint16 = 100 - RiskAssessmentBoundaryEntityType uint16 = 101 + OrganizationEntityType uint16 = 0 + FrameworkEntityType uint16 = 1 + MeasureEntityType uint16 = 2 + TaskEntityType uint16 = 3 + EvidenceEntityType uint16 = 4 + ConnectorEntityType uint16 = 5 + ThirdPartyRiskAssessmentEntityType uint16 = 6 + ThirdPartyEntityType uint16 = 7 + _ uint16 = 8 // PeopleEntityType - removed + ThirdPartyComplianceReportEntityType uint16 = 9 + DocumentEntityType uint16 = 10 + IdentityEntityType uint16 = 11 + SessionEntityType uint16 = 12 + EmailEntityType uint16 = 13 + ControlEntityType uint16 = 14 + RiskEntityType uint16 = 15 + DocumentVersionEntityType uint16 = 16 + DocumentVersionSignatureEntityType uint16 = 17 + AssetEntityType uint16 = 18 + DatumEntityType uint16 = 19 + AuditEntityType uint16 = 20 + _ uint16 = 21 // ReportEntityType - removed + TrustCenterEntityType uint16 = 22 + TrustCenterAccessEntityType uint16 = 23 + ThirdPartyBusinessAssociateAgreementEntityType uint16 = 24 + FileEntityType uint16 = 25 + ThirdPartyContactEntityType uint16 = 26 + ThirdPartyDataPrivacyAgreementEntityType uint16 = 27 + _ uint16 = 28 // NonconformityEntityType - removed + ObligationEntityType uint16 = 29 + ThirdPartyServiceEntityType uint16 = 30 + _ uint16 = 31 // SnapshotEntityType - removed + _ uint16 = 32 // ContinualImprovementEntityType - removed + ProcessingActivityEntityType uint16 = 33 + ExportJobEntityType uint16 = 34 + TrustCenterReferenceEntityType uint16 = 35 + TrustCenterDocumentAccessEntityType uint16 = 36 + CustomDomainEntityType uint16 = 37 + InvitationEntityType uint16 = 38 + MembershipEntityType uint16 = 39 + SlackMessageEntityType uint16 = 40 + TrustCenterFileEntityType uint16 = 41 + SAMLConfigurationEntityType uint16 = 42 + PersonalAPIKeyEntityType uint16 = 43 + _ uint16 = 44 // PersonalAPIKeyMembershipEntityType - removed + _ uint16 = 45 // MeetingEntityType - removed + DataProtectionImpactAssessmentEntityType uint16 = 46 + TransferImpactAssessmentEntityType uint16 = 47 + RightsRequestEntityType uint16 = 48 + StatementOfApplicabilityEntityType uint16 = 49 + ApplicabilityStatementEntityType uint16 = 50 + MembershipProfileEntityType uint16 = 51 + SCIMConfigurationEntityType uint16 = 52 + SCIMEventEntityType uint16 = 53 + TokenEntityType uint16 = 54 + SCIMBridgeEntityType uint16 = 55 + WebhookSubscriptionEntityType uint16 = 56 + WebhookDataEntityType uint16 = 57 + WebhookEventEntityType uint16 = 58 + ElectronicSignatureEntityType uint16 = 59 + ElectronicSignatureEventEntityType uint16 = 60 + EmailAttachmentEntityType uint16 = 61 + ComplianceFrameworkEntityType uint16 = 62 + ComplianceExternalURLEntityType uint16 = 63 + MailingListEntityType uint16 = 64 + MailingListSubscriberEntityType uint16 = 65 + MailingListUpdateEntityType uint16 = 66 + FindingEntityType uint16 = 67 + AuditLogEntryEntityType uint16 = 68 + DocumentVersionApprovalQuorumEntityType uint16 = 69 + DocumentVersionApprovalDecisionEntityType uint16 = 70 + AccessReviewSourceEntityType uint16 = 71 + AccessReviewCampaignEntityType uint16 = 72 + AccessReviewEntryEntityType uint16 = 73 + AccessReviewEntryDecisionHistoryEntityType uint16 = 74 + CookieBannerEntityType uint16 = 75 + CookieCategoryEntityType uint16 = 76 + CookieConsentRecordEntityType uint16 = 77 + CookieBannerVersionEntityType uint16 = 78 + OAuth2ClientEntityType uint16 = 79 + OAuth2ConsentEntityType uint16 = 80 + OAuth2AccessTokenEntityType uint16 = 81 + OAuth2RefreshTokenEntityType uint16 = 82 + OAuth2AuthorizationCodeEntityType uint16 = 83 + OAuth2DeviceCodeEntityType uint16 = 84 + _ uint16 = 85 // CookieEntityType - removed + CookieBannerTranslationEntityType uint16 = 86 + AgentRunEntityType uint16 = 87 + _ uint16 = 88 // CookiePatternEntityType - removed + TrackerPatternEntityType uint16 = 89 + DetectedTrackerEntityType uint16 = 90 + TrackerResourceEntityType uint16 = 91 + CommonThirdPartyEntityType uint16 = 92 + CommonThirdPartyDomainEntityType uint16 = 93 + CommonTrackerPatternEntityType uint16 = 94 + RiskAssessmentEntityType uint16 = 95 + RiskAssessmentNodeEntityType uint16 = 96 + RiskAssessmentProcessEntityType uint16 = 97 + RiskAssessmentThreatEntityType uint16 = 98 + RiskAssessmentScopeEntityType uint16 = 99 + RiskAssessmentScenarioEntityType uint16 = 100 + RiskAssessmentBoundaryEntityType uint16 = 101 + AccessReviewCampaignSourceEntityType uint16 = 102 + AccessReviewCampaignSourceFetchAttemptEntityType uint16 = 103 ) func NewEntityFromID(id gid.GID) (any, bool) { @@ -257,14 +259,14 @@ func NewEntityFromID(id gid.GID) (any, bool) { return &DocumentVersionApprovalDecision{ID: id}, true case DocumentVersionApprovalQuorumEntityType: return &DocumentVersionApprovalQuorum{ID: id}, true - case AccessSourceEntityType: - return &AccessSource{ID: id}, true + case AccessReviewSourceEntityType: + return &AccessReviewSource{ID: id}, true case AccessReviewCampaignEntityType: return &AccessReviewCampaign{ID: id}, true - case AccessEntryEntityType: - return &AccessEntry{ID: id}, true - case AccessEntryDecisionHistoryEntityType: - return &AccessEntryDecisionHistory{ID: id}, true + case AccessReviewEntryEntityType: + return &AccessReviewEntry{ID: id}, true + case AccessReviewEntryDecisionHistoryEntityType: + return &AccessReviewEntryDecisionHistory{ID: id}, true case CookieBannerEntityType: return &CookieBanner{ID: id}, true case CookieCategoryEntityType: @@ -315,6 +317,10 @@ func NewEntityFromID(id gid.GID) (any, bool) { return &RiskAssessmentScenario{ID: id}, true case RiskAssessmentBoundaryEntityType: return &RiskAssessmentBoundary{ID: id}, true + case AccessReviewCampaignSourceEntityType: + return &AccessReviewCampaignSource{ID: id}, true + case AccessReviewCampaignSourceFetchAttemptEntityType: + return &AccessReviewCampaignSourceFetchAttempt{ID: id}, true default: return nil, false } diff --git a/pkg/coredata/migrations/20260611T010000Z.sql b/pkg/coredata/migrations/20260611T010000Z.sql new file mode 100644 index 000000000..245a20804 --- /dev/null +++ b/pkg/coredata/migrations/20260611T010000Z.sql @@ -0,0 +1,151 @@ +-- Copyright (c) 2026 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. + +-- Access review: decouple campaign data from live sources. +-- +-- Introduces a per-campaign source snapshot (access_review_campaign_sources) +-- that owns the source identity (name/category/connector) so a review survives +-- the deletion of the live access source. Fetch tracking becomes an append-only +-- log (access_review_campaign_source_fetch_attempts) so each fetch run keeps its own +-- error. Access entries are repointed from the live source to the snapshot. + +-- Helper to mint a valid GID (base64url of: tenant 8B | entity type 2B | +-- timestamp 8B | random 6B) for back-filled rows. +CREATE FUNCTION pg_temp.gen_gid(tenant_text text, entity_type int) RETURNS text AS $$ + SELECT translate( + encode( + decode(rpad(translate(tenant_text, '-_', '+/'), 12, '='), 'base64') + || set_byte(set_byte('\x0000'::bytea, 0, (entity_type >> 8) & 255), 1, entity_type & 255) + || int8send((extract(epoch FROM clock_timestamp()) * 1000)::bigint) + || substring(uuid_send(gen_random_uuid()) FROM 1 FOR 6), + 'base64'), + '+/', '-_') +$$ LANGUAGE sql VOLATILE; + +-- 1. Per-campaign source snapshot. The live access source link is nullable and +-- ON DELETE SET NULL so deleting a source preserves the review's snapshot. +CREATE TABLE access_review_campaign_sources ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + access_review_campaign_id TEXT NOT NULL REFERENCES access_review_campaigns(id) ON DELETE CASCADE, + access_source_id TEXT REFERENCES access_sources(id) ON DELETE SET NULL, + name TEXT NOT NULL, + category access_source_category NOT NULL, + connector_id TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + UNIQUE (access_review_campaign_id, access_source_id) +); + +-- 2. Append-only fetch attempts. Each run is a new row; the snapshot's current +-- state is the latest attempt. +CREATE TABLE access_review_campaign_source_fetch_attempts ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + access_review_campaign_source_id TEXT NOT NULL REFERENCES access_review_campaign_sources(id) ON DELETE CASCADE, + attempt_number INTEGER NOT NULL, + status access_review_campaign_source_fetch_status NOT NULL, + fetched_accounts_count INTEGER NOT NULL, + error TEXT, + started_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + UNIQUE (access_review_campaign_source_id, attempt_number) +); + +CREATE INDEX idx_fetch_attempts_queued + ON access_review_campaign_source_fetch_attempts (created_at) + WHERE status = 'QUEUED'; + +-- 3. Back-fill snapshots from every (campaign, source) pair already present in +-- the scope, entries, or fetches. +INSERT INTO access_review_campaign_sources ( + id, tenant_id, access_review_campaign_id, access_source_id, + name, category, connector_id, created_at, updated_at +) +SELECT + pg_temp.gen_gid(s.tenant_id, 102), + s.tenant_id, + p.access_review_campaign_id, + p.access_source_id, + s.name, + s.category, + s.connector_id, + now(), + now() +FROM ( + SELECT access_review_campaign_id, access_source_id + FROM access_review_campaign_scope_systems + UNION + SELECT access_review_campaign_id, access_source_id + FROM access_entries + UNION + SELECT access_review_campaign_id, access_source_id + FROM access_review_campaign_source_fetches +) p +JOIN access_sources s ON s.id = p.access_source_id; + +-- 4. Repoint access entries at the snapshot. +ALTER TABLE access_entries + ADD COLUMN access_review_campaign_source_id TEXT; + +UPDATE access_entries e +SET access_review_campaign_source_id = cs.id +FROM access_review_campaign_sources cs +WHERE cs.access_review_campaign_id = e.access_review_campaign_id + AND cs.access_source_id = e.access_source_id; + +ALTER TABLE access_entries + ALTER COLUMN access_review_campaign_source_id SET NOT NULL; + +ALTER TABLE access_entries + ADD CONSTRAINT access_entries_campaign_source_id_fkey + FOREIGN KEY (access_review_campaign_source_id) + REFERENCES access_review_campaign_sources(id) ON DELETE CASCADE; + +DROP INDEX idx_access_entries_campaign_source_account_key; + +CREATE UNIQUE INDEX idx_access_entries_campaign_source_account_key + ON access_entries (access_review_campaign_source_id, account_key); + +ALTER TABLE access_entries + DROP COLUMN access_source_id; + +-- 5. Migrate fetch rows into the append-only attempt log (one attempt each). +INSERT INTO access_review_campaign_source_fetch_attempts ( + id, tenant_id, access_review_campaign_source_id, attempt_number, + status, fetched_accounts_count, error, started_at, completed_at, + created_at, updated_at +) +SELECT + pg_temp.gen_gid(f.tenant_id, 103), + f.tenant_id, + cs.id, + GREATEST(f.attempt_count, 1), + f.status, + f.fetched_accounts_count, + f.last_error, + f.started_at, + f.completed_at, + f.created_at, + f.updated_at +FROM access_review_campaign_source_fetches f +JOIN access_review_campaign_sources cs + ON cs.access_review_campaign_id = f.access_review_campaign_id + AND cs.access_source_id = f.access_source_id; + +-- 6. Drop the superseded tables. +DROP TABLE access_review_campaign_source_fetches; +DROP TABLE access_review_campaign_scope_systems; diff --git a/pkg/coredata/migrations/20260612T010000Z.sql b/pkg/coredata/migrations/20260612T010000Z.sql new file mode 100644 index 000000000..5ec14ac1d --- /dev/null +++ b/pkg/coredata/migrations/20260612T010000Z.sql @@ -0,0 +1,84 @@ +-- Copyright (c) 2026 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. + +-- Normalize access-review table, column, and enum names. + +-- 1. Enum types +ALTER TYPE access_source_category RENAME TO access_review_source_category; +ALTER TYPE access_entry_decision RENAME TO access_review_entry_decision; +ALTER TYPE access_entry_incremental_tag RENAME TO access_review_entry_incremental_tag; +ALTER TYPE access_entry_flag RENAME TO access_review_entry_flag; + +-- 2. Live sources +ALTER TABLE access_sources RENAME TO access_review_sources; + +ALTER TABLE access_review_sources + RENAME CONSTRAINT access_sources_pkey TO access_review_sources_pkey; +ALTER TABLE access_review_sources + RENAME CONSTRAINT access_sources_organization_id_fkey TO access_review_sources_organization_id_fkey; +ALTER TABLE access_review_sources + RENAME CONSTRAINT access_sources_connector_id_fkey TO access_review_sources_connector_id_fkey; + +-- 3. Campaign source snapshots: live-source FK column +ALTER TABLE access_review_campaign_sources + RENAME COLUMN access_source_id TO access_review_source_id; + +ALTER TABLE access_review_campaign_sources + RENAME CONSTRAINT access_review_campaign_sources_access_source_id_fkey + TO access_review_campaign_sources_access_review_source_id_fkey; + +ALTER TABLE access_review_campaign_sources + DROP CONSTRAINT IF EXISTS access_review_campaign_sources_access_review_campaign_id_access_sour_key; +ALTER TABLE access_review_campaign_sources + DROP CONSTRAINT IF EXISTS access_review_campaign_sources_access_review_campaign_id_access_source_id_key; +ALTER TABLE access_review_campaign_sources + ADD CONSTRAINT access_review_campaign_sources_campaign_source_unique + UNIQUE (access_review_campaign_id, access_review_source_id); + +-- 4. Entries +ALTER TABLE access_entries RENAME TO access_review_entries; + +ALTER TABLE access_review_entries + RENAME CONSTRAINT access_entries_pkey TO access_review_entries_pkey; +ALTER TABLE access_review_entries + RENAME CONSTRAINT access_entries_access_review_campaign_id_fkey + TO access_review_entries_access_review_campaign_id_fkey; +ALTER TABLE access_review_entries + RENAME CONSTRAINT access_entries_identity_id_fkey + TO access_review_entries_identity_id_fkey; +ALTER TABLE access_review_entries + RENAME CONSTRAINT access_entries_organization_id_fkey + TO access_review_entries_organization_id_fkey; +ALTER TABLE access_review_entries + RENAME CONSTRAINT access_entries_campaign_source_id_fkey + TO access_review_entries_campaign_source_id_fkey; + +ALTER INDEX idx_access_entries_campaign_source_account_key + RENAME TO idx_access_review_entries_campaign_source_account_key; + +-- 5. Decision history +ALTER TABLE access_entry_decision_history RENAME TO access_review_entry_decision_history; + +ALTER TABLE access_review_entry_decision_history + RENAME CONSTRAINT access_entry_decision_history_pkey + TO access_review_entry_decision_history_pkey; +ALTER TABLE access_review_entry_decision_history + RENAME CONSTRAINT access_entry_decision_history_access_entry_id_fkey + TO access_review_entry_decision_history_access_review_entry_id_fkey; +ALTER TABLE access_review_entry_decision_history + RENAME CONSTRAINT access_entry_decision_history_organization_id_fkey + TO access_review_entry_decision_history_organization_id_fkey; + +ALTER TABLE access_review_entry_decision_history + RENAME COLUMN access_entry_id TO access_review_entry_id; diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index 5e53effee..de8664b2b 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -1703,11 +1703,11 @@ func (s OrganizationService) DeleteSCIMConfiguration( if err == nil { // Bridge exists. Only delete the underlying connector if nothing - // else references it (e.g. access_sources). Otherwise leave it in + // else references it (e.g. access_review_sources). Otherwise leave it in // place — the bridge's FK is ON DELETE SET NULL, so deleting the // bridge alone is sufficient to unbind SCIM from the connector. if bridge.ConnectorID != nil { - accessSources := &coredata.AccessSources{} + accessSources := &coredata.AccessReviewSources{} count, err := accessSources.CountByConnectorID(ctx, tx, scope, *bridge.ConnectorID) if err != nil { diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index 18a3e14a5..1751c92d1 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -354,32 +354,6 @@ const ( ActionWebhookSubscriptionUpdate = "core:webhook-subscription:update" ActionWebhookSubscriptionDelete = "core:webhook-subscription:delete" - // AccessReviewCampaign actions - ActionAccessReviewCampaignGet = "core:access-review-campaign:get" - ActionAccessReviewCampaignList = "core:access-review-campaign:list" - ActionAccessReviewCampaignCreate = "core:access-review-campaign:create" - ActionAccessReviewCampaignUpdate = "core:access-review-campaign:update" - ActionAccessReviewCampaignDelete = "core:access-review-campaign:delete" - ActionAccessReviewCampaignStart = "core:access-review-campaign:start" - ActionAccessReviewCampaignClose = "core:access-review-campaign:close" - ActionAccessReviewCampaignCancel = "core:access-review-campaign:cancel" - ActionAccessReviewCampaignAddScopeSource = "core:access-review-campaign:add-scope-source" - ActionAccessReviewCampaignRemoveScopeSource = "core:access-review-campaign:remove-scope-source" - - // AccessEntry actions - ActionAccessEntryGet = "core:access-entry:get" - ActionAccessEntryList = "core:access-entry:list" - ActionAccessEntryDecide = "core:access-entry:decide" - ActionAccessEntryFlag = "core:access-entry:flag" - - // AccessSource actions - ActionAccessSourceGet = "core:access-source:get" - ActionAccessSourceList = "core:access-source:list" - ActionAccessSourceCreate = "core:access-source:create" - ActionAccessSourceUpdate = "core:access-source:update" - ActionAccessSourceDelete = "core:access-source:delete" - ActionAccessSourceSync = "core:access-source:sync" - // CookieBanner actions ActionCookieBannerGet = "core:cookie-banner:get" ActionCookieBannerList = "core:cookie-banner:list" diff --git a/pkg/probo/policies.go b/pkg/probo/policies.go index 30a1dace9..c1c7509ea 100644 --- a/pkg/probo/policies.go +++ b/pkg/probo/policies.go @@ -82,9 +82,6 @@ var ViewerPolicy = policy.NewPolicy( ActionStatementOfApplicabilityGet, ActionStatementOfApplicabilityList, ActionApplicabilityStatementGet, ActionApplicabilityStatementList, ActionWebhookSubscriptionGet, ActionWebhookSubscriptionList, - ActionAccessReviewCampaignGet, ActionAccessReviewCampaignList, - ActionAccessEntryGet, ActionAccessEntryList, - ActionAccessSourceGet, ActionAccessSourceList, ActionCookieBannerGet, ActionCookieBannerList, ActionCookieBannerVersionGet, ActionCookieBannerVersionList, ActionCookieCategoryGet, ActionCookieCategoryList, diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index a3f9655c3..9feb3abf2 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -595,6 +595,7 @@ func (impl *Implm) Run( agentRunService := agentrun.NewService(pgClient) iamService.Authorizer.RegisterPolicySet(agentrun.PolicySet()) + iamService.Authorizer.RegisterPolicySet(accessreview.PolicySet()) thirdPartyService := thirdparty.NewService(pgClient, fileManagerService, thirdPartyVetter) riskManagementService := riskmanagement.NewService(pgClient) diff --git a/pkg/server/api/console/v1/access_review_campaign_resolvers.go b/pkg/server/api/console/v1/access_review_campaign_resolvers.go index 97ab283a9..fd51f1d38 100644 --- a/pkg/server/api/console/v1/access_review_campaign_resolvers.go +++ b/pkg/server/api/console/v1/access_review_campaign_resolvers.go @@ -24,98 +24,6 @@ import ( "go.probo.inc/probo/pkg/server/gqlutils" ) -// Campaign is the resolver for the campaign field. -func (r *accessEntryResolver) Campaign(ctx context.Context, obj *types.AccessEntry) (*types.AccessReviewCampaign, error) { - scope, err := r.authorize(ctx, obj.Campaign.ID, probo.ActionAccessReviewCampaignGet) - if err != nil { - return nil, err - } - - campaign, err := r.accessReview.Campaigns(scope).Get(ctx, obj.Campaign.ID) - if err != nil { - if errors.Is(err, coredata.ErrResourceNotFound) { - return nil, gqlutils.NotFound(ctx, err) - } - - panic(fmt.Errorf("cannot get access review campaign: %w", err)) - } - - return types.NewAccessReviewCampaign(campaign), nil -} - -// AccessSource is the resolver for the accessSource field. -func (r *accessEntryResolver) AccessSource(ctx context.Context, obj *types.AccessEntry) (*types.AccessSource, error) { - scope, err := r.authorize(ctx, obj.AccessSource.ID, probo.ActionAccessSourceGet) - if err != nil { - return nil, err - } - - source, err := r.accessReview.Sources(scope).Get(ctx, obj.AccessSource.ID) - if err != nil { - if errors.Is(err, coredata.ErrResourceNotFound) { - return nil, gqlutils.NotFound(ctx, err) - } - - panic(fmt.Errorf("cannot get access source: %w", err)) - } - - return types.NewAccessSource(source), nil -} - -// DecisionHistory is the resolver for the decisionHistory field. -func (r *accessEntryResolver) DecisionHistory(ctx context.Context, obj *types.AccessEntry) ([]*types.AccessEntryDecisionHistoryEntry, error) { - scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessEntryGet) - if err != nil { - return nil, err - } - - histories, err := r.accessReview.Entries(scope).DecisionHistory(ctx, obj.ID) - if err != nil { - panic(fmt.Errorf("cannot get decision history: %w", err)) - } - - result := make([]*types.AccessEntryDecisionHistoryEntry, len(histories)) - for i, h := range histories { - result[i] = types.NewAccessEntryDecisionHistoryEntry(h) - } - - return result, nil -} - -// Permission is the resolver for the permission field. -func (r *accessEntryResolver) Permission(ctx context.Context, obj *types.AccessEntry, action string) (bool, error) { - return r.Resolver.Permission(ctx, obj, action) -} - -// TotalCount is the resolver for the totalCount field. -func (r *accessEntryConnectionResolver) TotalCount(ctx context.Context, obj *types.AccessEntryConnection) (int, error) { - scope, err := r.authorize(ctx, obj.ParentID, probo.ActionAccessEntryList) - if err != nil { - return 0, err - } - - switch obj.Resolver.(type) { - case *accessReviewCampaignResolver: - if obj.SourceID != nil { - count, err := r.accessReview.Entries(scope).CountForCampaignIDAndSourceID(ctx, obj.ParentID, *obj.SourceID, obj.Filter) - if err != nil { - panic(fmt.Errorf("cannot count access entries: %w", err)) - } - - return count, nil - } - - count, err := r.accessReview.Entries(scope).CountForCampaignID(ctx, obj.ParentID, obj.Filter) - if err != nil { - panic(fmt.Errorf("cannot count access entries: %w", err)) - } - - return count, nil - } - - panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver)) -} - // Organization is the resolver for the organization field. func (r *accessReviewCampaignResolver) Organization(ctx context.Context, obj *types.AccessReviewCampaign) (*types.Organization, error) { if _, err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil { @@ -138,50 +46,50 @@ func (r *accessReviewCampaignResolver) Organization(ctx context.Context, obj *ty return types.NewOrganization(organization), nil } -// ScopeSources is the resolver for the scopeSources field. -func (r *accessReviewCampaignResolver) ScopeSources(ctx context.Context, obj *types.AccessReviewCampaign) ([]*types.AccessReviewCampaignScopeSource, error) { - scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceList) +// Sources is the resolver for the sources field. +func (r *accessReviewCampaignResolver) Sources(ctx context.Context, obj *types.AccessReviewCampaign) ([]*types.AccessReviewCampaignSource, error) { + scope, err := r.authorize(ctx, obj.ID, accessreview.ActionCampaignGet) if err != nil { return nil, err } - sources, err := r.accessReview.Sources(scope).ListScopeSourcesForCampaignID(ctx, obj.ID) + campaignSources, err := r.accessReview.ListCampaignSources(ctx, scope, obj.ID) if err != nil { - panic(fmt.Errorf("cannot list scope sources: %w", err)) + panic(fmt.Errorf("cannot list campaign sources: %w", err)) } - fetches, err := r.accessReview.Campaigns(scope).ListSourceFetches(ctx, obj.ID) + attempts, err := r.accessReview.ListLatestFetchAttempts(ctx, scope, obj.ID) if err != nil { - panic(fmt.Errorf("cannot list source fetch states: %w", err)) + panic(fmt.Errorf("cannot list latest fetch attempts: %w", err)) } - fetchBySourceID := make(map[gid.GID]*coredata.AccessReviewCampaignSourceFetch, len(fetches)) - for _, fetch := range fetches { - fetchBySourceID[fetch.AccessSourceID] = fetch + attemptByCampaignSourceID := make(map[gid.GID]*coredata.AccessReviewCampaignSourceFetchAttempt, len(attempts)) + for _, attempt := range attempts { + attemptByCampaignSourceID[attempt.AccessReviewCampaignSourceID] = attempt } - result := make([]*types.AccessReviewCampaignScopeSource, len(sources)) - for i, s := range sources { - result[i] = types.NewAccessReviewCampaignScopeSource(obj.ID, s, fetchBySourceID[s.ID]) + result := make([]*types.AccessReviewCampaignSource, len(campaignSources)) + for i, campaignSource := range campaignSources { + result[i] = types.NewAccessReviewCampaignSource(campaignSource, attemptByCampaignSourceID[campaignSource.ID]) } return result, nil } // Entries is the resolver for the entries field. -func (r *accessReviewCampaignResolver) Entries(ctx context.Context, obj *types.AccessReviewCampaign, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessEntryOrder, accessSourceID *gid.GID, filter *coredata.AccessEntryFilter) (*types.AccessEntryConnection, error) { - scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessEntryList) +func (r *accessReviewCampaignResolver) Entries(ctx context.Context, obj *types.AccessReviewCampaign, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessReviewEntryOrder, campaignSourceID *gid.GID, filter *coredata.AccessReviewEntryFilter) (*types.AccessReviewEntryConnection, error) { + scope, err := r.authorize(ctx, obj.ID, accessreview.ActionEntryList) if err != nil { return nil, err } - pageOrderBy := page.OrderBy[coredata.AccessEntryOrderField]{ - Field: coredata.AccessEntryOrderFieldCreatedAt, + pageOrderBy := page.OrderBy[coredata.AccessReviewEntryOrderField]{ + Field: coredata.AccessReviewEntryOrderFieldCreatedAt, Direction: page.OrderDirectionDesc, } if orderBy != nil { - pageOrderBy = page.OrderBy[coredata.AccessEntryOrderField]{ + pageOrderBy = page.OrderBy[coredata.AccessReviewEntryOrderField]{ Field: orderBy.Field, Direction: orderBy.Direction, } @@ -190,30 +98,30 @@ func (r *accessReviewCampaignResolver) Entries(ctx context.Context, obj *types.A cursor := types.NewCursor(first, after, last, before, pageOrderBy) var ( - p *page.Page[*coredata.AccessEntry, coredata.AccessEntryOrderField] + p *page.Page[*coredata.AccessReviewEntry, coredata.AccessReviewEntryOrderField] ) - if accessSourceID != nil { - p, err = r.accessReview.Entries(scope).ListForCampaignIDAndSourceID(ctx, obj.ID, *accessSourceID, cursor, filter) + if campaignSourceID != nil { + p, err = r.accessReview.ListEntriesForCampaignIDAndSourceID(ctx, scope, obj.ID, *campaignSourceID, cursor, filter) } else { - p, err = r.accessReview.Entries(scope).ListForCampaignID(ctx, obj.ID, cursor, filter) + p, err = r.accessReview.ListEntriesForCampaignID(ctx, scope, obj.ID, cursor, filter) } if err != nil { panic(fmt.Errorf("cannot list access entries: %w", err)) } - return types.NewAccessEntryConnection(p, r, obj.ID, accessSourceID, filter), nil + return types.NewAccessReviewEntryConnection(p, r, obj.ID, campaignSourceID, filter), nil } // PendingEntryCount is the resolver for the pendingEntryCount field. func (r *accessReviewCampaignResolver) PendingEntryCount(ctx context.Context, obj *types.AccessReviewCampaign) (int, error) { - scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessEntryList) + scope, err := r.authorize(ctx, obj.ID, accessreview.ActionEntryList) if err != nil { return 0, err } - count, err := r.accessReview.Entries(scope).CountPendingForCampaignID(ctx, obj.ID) + count, err := r.accessReview.CountPendingEntriesForCampaignID(ctx, scope, obj.ID) if err != nil { panic(fmt.Errorf("cannot count pending access entries: %w", err)) } @@ -222,18 +130,18 @@ func (r *accessReviewCampaignResolver) PendingEntryCount(ctx context.Context, ob } // Statistics is the resolver for the statistics field. -func (r *accessReviewCampaignResolver) Statistics(ctx context.Context, obj *types.AccessReviewCampaign) (*types.AccessReviewCampaignStatistics, error) { - scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessEntryList) +func (r *accessReviewCampaignResolver) Statistics(ctx context.Context, obj *types.AccessReviewCampaign) (*types.AccessReviewStatistics, error) { + scope, err := r.authorize(ctx, obj.ID, accessreview.ActionEntryList) if err != nil { return nil, err } - stats, err := r.accessReview.Entries(scope).Statistics(ctx, obj.ID) + stats, err := r.accessReview.CampaignStatistics(ctx, scope, obj.ID) if err != nil { panic(fmt.Errorf("cannot get campaign statistics: %w", err)) } - return types.NewAccessReviewCampaignStatistics(stats), nil + return types.NewAccessReviewStatistics(stats), nil } // Permission is the resolver for the permission field. @@ -243,14 +151,14 @@ func (r *accessReviewCampaignResolver) Permission(ctx context.Context, obj *type // TotalCount is the resolver for the totalCount field. func (r *accessReviewCampaignConnectionResolver) TotalCount(ctx context.Context, obj *types.AccessReviewCampaignConnection) (int, error) { - scope, err := r.authorize(ctx, obj.ParentID, probo.ActionAccessReviewCampaignList) + scope, err := r.authorize(ctx, obj.ParentID, accessreview.ActionCampaignList) if err != nil { return 0, err } switch obj.Resolver.(type) { case *organizationResolver: - count, err := r.accessReview.Campaigns(scope).CountForOrganizationID(ctx, obj.ParentID) + count, err := r.accessReview.CountCampaignsForOrganizationID(ctx, scope, obj.ParentID) if err != nil { panic(fmt.Errorf("cannot count access review campaigns: %w", err)) } @@ -261,20 +169,63 @@ func (r *accessReviewCampaignConnectionResolver) TotalCount(ctx context.Context, panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver)) } -// Entries is the resolver for the entries field. -func (r *accessReviewCampaignScopeSourceResolver) Entries(ctx context.Context, obj *types.AccessReviewCampaignScopeSource, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessEntryOrder, filter *coredata.AccessEntryFilter) (*types.AccessEntryConnection, error) { - scope, err := r.authorize(ctx, obj.CampaignID, probo.ActionAccessEntryList) +// Source is the resolver for the source field. +func (r *accessReviewCampaignSourceResolver) Source(ctx context.Context, obj *types.AccessReviewCampaignSource) (*types.AccessReviewSource, error) { + if obj.SourceID == nil { + return nil, nil + } + + scope, err := r.authorize(ctx, obj.CampaignID, accessreview.ActionCampaignGet) if err != nil { return nil, err } - pageOrderBy := page.OrderBy[coredata.AccessEntryOrderField]{ - Field: coredata.AccessEntryOrderFieldCreatedAt, + source, err := r.accessReview.GetSource(ctx, scope, *obj.SourceID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, nil + } + + panic(fmt.Errorf("cannot get access review source: %w", err)) + } + + return types.NewAccessReviewSource(source), nil +} + +// FetchAttempts is the resolver for the fetchAttempts field. +func (r *accessReviewCampaignSourceResolver) FetchAttempts(ctx context.Context, obj *types.AccessReviewCampaignSource) ([]*types.AccessReviewCampaignSourceFetchAttempt, error) { + scope, err := r.authorize(ctx, obj.CampaignID, accessreview.ActionCampaignGet) + if err != nil { + return nil, err + } + + attempts, err := r.accessReview.ListFetchAttempts(ctx, scope, obj.ID) + if err != nil { + panic(fmt.Errorf("cannot list fetch attempts: %w", err)) + } + + result := make([]*types.AccessReviewCampaignSourceFetchAttempt, len(attempts)) + for i, attempt := range attempts { + result[i] = types.NewAccessReviewCampaignSourceFetchAttempt(attempt) + } + + return result, nil +} + +// Entries is the resolver for the entries field. +func (r *accessReviewCampaignSourceResolver) Entries(ctx context.Context, obj *types.AccessReviewCampaignSource, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessReviewEntryOrder, filter *coredata.AccessReviewEntryFilter) (*types.AccessReviewEntryConnection, error) { + scope, err := r.authorize(ctx, obj.CampaignID, accessreview.ActionEntryList) + if err != nil { + return nil, err + } + + pageOrderBy := page.OrderBy[coredata.AccessReviewEntryOrderField]{ + Field: coredata.AccessReviewEntryOrderFieldCreatedAt, Direction: page.OrderDirectionDesc, } if orderBy != nil { - pageOrderBy = page.OrderBy[coredata.AccessEntryOrderField]{ + pageOrderBy = page.OrderBy[coredata.AccessReviewEntryOrderField]{ Field: orderBy.Field, Direction: orderBy.Direction, } @@ -282,33 +233,147 @@ func (r *accessReviewCampaignScopeSourceResolver) Entries(ctx context.Context, o cursor := types.NewCursor(first, after, last, before, pageOrderBy) - p, err := r.accessReview.Entries(scope).ListForCampaignIDAndSourceID(ctx, obj.CampaignID, obj.ID, cursor, filter) + p, err := r.accessReview.ListEntriesForCampaignIDAndSourceID(ctx, scope, obj.CampaignID, obj.ID, cursor, filter) if err != nil { panic(fmt.Errorf("cannot list access entries: %w", err)) } sourceID := obj.ID - return types.NewAccessEntryConnection(p, r, obj.CampaignID, &sourceID, filter), nil + return types.NewAccessReviewEntryConnection(p, r, obj.CampaignID, &sourceID, filter), nil } // Statistics is the resolver for the statistics field. -func (r *accessReviewCampaignScopeSourceResolver) Statistics(ctx context.Context, obj *types.AccessReviewCampaignScopeSource) (*types.AccessReviewCampaignStatistics, error) { - scope, err := r.authorize(ctx, obj.CampaignID, probo.ActionAccessEntryList) +func (r *accessReviewCampaignSourceResolver) Statistics(ctx context.Context, obj *types.AccessReviewCampaignSource) (*types.AccessReviewStatistics, error) { + scope, err := r.authorize(ctx, obj.CampaignID, accessreview.ActionEntryList) if err != nil { return nil, err } - stats, err := r.accessReview.Entries(scope).StatisticsForSource(ctx, obj.CampaignID, obj.ID) + stats, err := r.accessReview.CampaignSourceStatistics(ctx, scope, obj.CampaignID, obj.ID) if err != nil { panic(fmt.Errorf("cannot get source statistics: %w", err)) } - return types.NewAccessReviewCampaignStatistics(stats), nil + return types.NewAccessReviewStatistics(stats), nil +} + +// Campaign is the resolver for the campaign field. +func (r *accessReviewEntryResolver) Campaign(ctx context.Context, obj *types.AccessReviewEntry) (*types.AccessReviewCampaign, error) { + scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet) + if err != nil { + return nil, err + } + + campaign, err := r.accessReview.GetCampaign(ctx, scope, obj.Campaign.ID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + + panic(fmt.Errorf("cannot get access review campaign: %w", err)) + } + + return types.NewAccessReviewCampaign(campaign), nil +} + +// AccessReviewSource is the resolver for the accessReviewSource field. +func (r *accessReviewEntryResolver) AccessReviewSource(ctx context.Context, obj *types.AccessReviewEntry) (*types.AccessReviewSource, error) { + scope, err := r.authorize(ctx, obj.ID, accessreview.ActionEntryGet) + if err != nil { + return nil, err + } + + entry, err := r.accessReview.GetEntry(ctx, scope, obj.ID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + + panic(fmt.Errorf("cannot get access review entry: %w", err)) + } + + campaignSource, err := r.accessReview.GetCampaignSource(ctx, scope, entry.AccessReviewCampaignSourceID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, nil + } + + panic(fmt.Errorf("cannot get campaign source: %w", err)) + } + + if campaignSource.AccessReviewSourceID == nil { + return nil, nil + } + + source, err := r.accessReview.GetSource(ctx, scope, *campaignSource.AccessReviewSourceID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, nil + } + + panic(fmt.Errorf("cannot get access review source: %w", err)) + } + + return types.NewAccessReviewSource(source), nil +} + +// DecisionHistory is the resolver for the decisionHistory field. +func (r *accessReviewEntryResolver) DecisionHistory(ctx context.Context, obj *types.AccessReviewEntry) ([]*types.AccessReviewEntryDecisionHistoryEntry, error) { + scope, err := r.authorize(ctx, obj.ID, accessreview.ActionEntryGet) + if err != nil { + return nil, err + } + + histories, err := r.accessReview.EntryDecisionHistory(ctx, scope, obj.ID) + if err != nil { + panic(fmt.Errorf("cannot get decision history: %w", err)) + } + + result := make([]*types.AccessReviewEntryDecisionHistoryEntry, len(histories)) + for i, h := range histories { + result[i] = types.NewAccessReviewEntryDecisionHistoryEntry(h) + } + + return result, nil +} + +// Permission is the resolver for the permission field. +func (r *accessReviewEntryResolver) Permission(ctx context.Context, obj *types.AccessReviewEntry, action string) (bool, error) { + return r.Resolver.Permission(ctx, obj, action) +} + +// TotalCount is the resolver for the totalCount field. +func (r *accessReviewEntryConnectionResolver) TotalCount(ctx context.Context, obj *types.AccessReviewEntryConnection) (int, error) { + scope, err := r.authorize(ctx, obj.ParentID, accessreview.ActionEntryList) + if err != nil { + return 0, err + } + + switch obj.Resolver.(type) { + case *accessReviewCampaignResolver: + if obj.SourceID != nil { + count, err := r.accessReview.CountEntriesForCampaignIDAndSourceID(ctx, scope, obj.ParentID, *obj.SourceID, obj.Filter) + if err != nil { + panic(fmt.Errorf("cannot count access entries: %w", err)) + } + + return count, nil + } + + count, err := r.accessReview.CountEntriesForCampaignID(ctx, scope, obj.ParentID, obj.Filter) + if err != nil { + panic(fmt.Errorf("cannot count access entries: %w", err)) + } + + return count, nil + } + + panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver)) } // Organization is the resolver for the organization field. -func (r *accessSourceResolver) Organization(ctx context.Context, obj *types.AccessSource) (*types.Organization, error) { +func (r *accessReviewSourceResolver) Organization(ctx context.Context, obj *types.AccessReviewSource) (*types.Organization, error) { if _, err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil { return nil, err } @@ -330,12 +395,12 @@ func (r *accessSourceResolver) Organization(ctx context.Context, obj *types.Acce } // Connector is the resolver for the connector field. -func (r *accessSourceResolver) Connector(ctx context.Context, obj *types.AccessSource) (*types.Connector, error) { +func (r *accessReviewSourceResolver) Connector(ctx context.Context, obj *types.AccessReviewSource) (*types.Connector, error) { if obj.ConnectorID == nil { return nil, nil } - scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceGet) + scope, err := r.authorize(ctx, obj.ID, accessreview.ActionSourceGet) if err != nil { return nil, err } @@ -353,8 +418,8 @@ func (r *accessSourceResolver) Connector(ctx context.Context, obj *types.AccessS } // ProviderOrganizations is the resolver for the providerOrganizations field. -func (r *accessSourceResolver) ProviderOrganizations(ctx context.Context, obj *types.AccessSource) ([]*types.ProviderOrganization, error) { - scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceGet) +func (r *accessReviewSourceResolver) ProviderOrganizations(ctx context.Context, obj *types.AccessReviewSource) ([]*types.ProviderOrganization, error) { + scope, err := r.authorize(ctx, obj.ID, accessreview.ActionSourceGet) if err != nil { return nil, err } @@ -363,7 +428,7 @@ func (r *accessSourceResolver) ProviderOrganizations(ctx context.Context, obj *t return []*types.ProviderOrganization{}, nil } - httpClient, dbConnector, err := r.accessReview.Sources(scope).ConnectorHTTPClient(ctx, *obj.ConnectorID) + httpClient, dbConnector, err := r.accessReview.ConnectorHTTPClient(ctx, scope, *obj.ConnectorID) if err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { return []*types.ProviderOrganization{}, nil @@ -396,8 +461,8 @@ func (r *accessSourceResolver) ProviderOrganizations(ctx context.Context, obj *t // not yet picked an org. 2-auto providers (PagerDuty, Vercel) always // return false: the identifier is captured during the OAuth callback, // not via a follow-up configure mutation. -func (r *accessSourceResolver) NeedsConfiguration(ctx context.Context, obj *types.AccessSource) (bool, error) { - scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceGet) +func (r *accessReviewSourceResolver) NeedsConfiguration(ctx context.Context, obj *types.AccessReviewSource) (bool, error) { + scope, err := r.authorize(ctx, obj.ID, accessreview.ActionSourceGet) if err != nil { return false, err } @@ -424,27 +489,27 @@ func (r *accessSourceResolver) NeedsConfiguration(ctx context.Context, obj *type } // ConnectionStatus is the resolver for the connectionStatus field. -func (r *accessSourceResolver) ConnectionStatus(ctx context.Context, obj *types.AccessSource) (types.AccessSourceConnectionStatus, error) { +func (r *accessReviewSourceResolver) ConnectionStatus(ctx context.Context, obj *types.AccessReviewSource) (types.AccessReviewSourceConnectionStatus, error) { if obj.ConnectorID == nil { - return types.AccessSourceConnectionStatusNotApplicable, nil + return types.AccessReviewSourceConnectionStatusNotApplicable, nil } - scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceGet) + scope, err := r.authorize(ctx, obj.ID, accessreview.ActionSourceGet) if err != nil { - return types.AccessSourceConnectionStatusNotApplicable, err + return types.AccessReviewSourceConnectionStatusNotApplicable, err } - httpClient, dbConnector, err := r.accessReview.Sources(scope).ConnectorHTTPClient(ctx, *obj.ConnectorID) + httpClient, dbConnector, err := r.accessReview.ConnectorHTTPClient(ctx, scope, *obj.ConnectorID) if err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { - return types.AccessSourceConnectionStatusNotApplicable, nil + return types.AccessReviewSourceConnectionStatusNotApplicable, nil } - return types.AccessSourceConnectionStatusDisconnected, nil + return types.AccessReviewSourceConnectionStatusDisconnected, nil } if dbConnector.Protocol != coredata.ConnectorProtocolOAuth2 { - return types.AccessSourceConnectionStatusConnected, nil + return types.AccessReviewSourceConnectionStatusConnected, nil } // Creating an HTTP client may succeed even with an expired token @@ -452,15 +517,15 @@ func (r *accessSourceResolver) ConnectionStatus(ctx context.Context, obj *types. // request to verify the token is actually valid. probeURL := r.providerRegistry.ProbeURL(string(dbConnector.Provider)) if err := probeConnection(ctx, httpClient, probeURL); err != nil { - return types.AccessSourceConnectionStatusDisconnected, nil + return types.AccessReviewSourceConnectionStatusDisconnected, nil } - return types.AccessSourceConnectionStatusConnected, nil + return types.AccessReviewSourceConnectionStatusConnected, nil } // SelectedOrganization is the resolver for the selectedOrganization field. -func (r *accessSourceResolver) SelectedOrganization(ctx context.Context, obj *types.AccessSource) (*string, error) { - scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceGet) +func (r *accessReviewSourceResolver) SelectedOrganization(ctx context.Context, obj *types.AccessReviewSource) (*string, error) { + scope, err := r.authorize(ctx, obj.ID, accessreview.ActionSourceGet) if err != nil { return nil, err } @@ -492,20 +557,20 @@ func (r *accessSourceResolver) SelectedOrganization(ctx context.Context, obj *ty } // Permission is the resolver for the permission field. -func (r *accessSourceResolver) Permission(ctx context.Context, obj *types.AccessSource, action string) (bool, error) { +func (r *accessReviewSourceResolver) Permission(ctx context.Context, obj *types.AccessReviewSource, action string) (bool, error) { return r.Resolver.Permission(ctx, obj, action) } // TotalCount is the resolver for the totalCount field. -func (r *accessSourceConnectionResolver) TotalCount(ctx context.Context, obj *types.AccessSourceConnection) (int, error) { - scope, err := r.authorize(ctx, obj.ParentID, probo.ActionAccessSourceList) +func (r *accessReviewSourceConnectionResolver) TotalCount(ctx context.Context, obj *types.AccessReviewSourceConnection) (int, error) { + scope, err := r.authorize(ctx, obj.ParentID, accessreview.ActionSourceList) if err != nil { return 0, err } switch obj.Resolver.(type) { case *organizationResolver: - count, err := r.accessReview.Sources(scope).CountForOrganizationID(ctx, obj.ParentID) + count, err := r.accessReview.CountSourcesForOrganizationID(ctx, scope, obj.ParentID) if err != nil { panic(fmt.Errorf("cannot count access sources: %w", err)) } @@ -516,38 +581,38 @@ func (r *accessSourceConnectionResolver) TotalCount(ctx context.Context, obj *ty panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver)) } -// CreateAccessSource is the resolver for the createAccessSource field. -func (r *mutationResolver) CreateAccessSource(ctx context.Context, input types.CreateAccessSourceInput) (*types.CreateAccessSourcePayload, error) { - scope, err := r.authorize(ctx, input.OrganizationID, probo.ActionAccessSourceCreate) +// CreateAccessReviewSource is the resolver for the createAccessReviewSource field. +func (r *mutationResolver) CreateAccessReviewSource(ctx context.Context, input types.CreateAccessReviewSourceInput) (*types.CreateAccessReviewSourcePayload, error) { + scope, err := r.authorize(ctx, input.OrganizationID, accessreview.ActionSourceCreate) if err != nil { return nil, err } - source, err := r.accessReview.Sources(scope).Create(ctx, accessreview.CreateAccessSourceRequest{ + source, err := r.accessReview.CreateSource(ctx, scope, accessreview.CreateAccessReviewSourceRequest{ OrganizationID: input.OrganizationID, ConnectorID: input.ConnectorID, Name: input.Name, - Category: coredata.AccessSourceCategorySaaS, + Category: coredata.AccessReviewSourceCategorySaaS, CsvData: input.CSVData, }) if err != nil { panic(fmt.Errorf("cannot create access source: %w", err)) } - return &types.CreateAccessSourcePayload{ - AccessSourceEdge: types.NewAccessSourceEdge(source, coredata.AccessSourceOrderFieldCreatedAt), + return &types.CreateAccessReviewSourcePayload{ + AccessReviewSourceEdge: types.NewAccessReviewSourceEdge(source, coredata.AccessReviewSourceOrderFieldCreatedAt), }, nil } -// UpdateAccessSource is the resolver for the updateAccessSource field. -func (r *mutationResolver) UpdateAccessSource(ctx context.Context, input types.UpdateAccessSourceInput) (*types.UpdateAccessSourcePayload, error) { - scope, err := r.authorize(ctx, input.AccessSourceID, probo.ActionAccessSourceUpdate) +// UpdateAccessReviewSource is the resolver for the updateAccessReviewSource field. +func (r *mutationResolver) UpdateAccessReviewSource(ctx context.Context, input types.UpdateAccessReviewSourceInput) (*types.UpdateAccessReviewSourcePayload, error) { + scope, err := r.authorize(ctx, input.AccessReviewSourceID, accessreview.ActionSourceUpdate) if err != nil { return nil, err } - req := accessreview.UpdateAccessSourceRequest{ - AccessSourceID: input.AccessSourceID, + req := accessreview.UpdateAccessReviewSourceRequest{ + AccessReviewSourceID: input.AccessReviewSourceID, } if input.Name.IsSet() { @@ -562,7 +627,7 @@ func (r *mutationResolver) UpdateAccessSource(ctx context.Context, input types.U req.CsvData = gqlutils.UnwrapOmittable(input.CSVData) } - source, err := r.accessReview.Sources(scope).Update(ctx, req) + source, err := r.accessReview.UpdateSource(ctx, scope, req) if err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { return nil, gqlutils.NotFound(ctx, err) @@ -571,19 +636,19 @@ func (r *mutationResolver) UpdateAccessSource(ctx context.Context, input types.U panic(fmt.Errorf("cannot update access source: %w", err)) } - return &types.UpdateAccessSourcePayload{ - AccessSource: types.NewAccessSource(source), + return &types.UpdateAccessReviewSourcePayload{ + AccessReviewSource: types.NewAccessReviewSource(source), }, nil } -// DeleteAccessSource is the resolver for the deleteAccessSource field. -func (r *mutationResolver) DeleteAccessSource(ctx context.Context, input types.DeleteAccessSourceInput) (*types.DeleteAccessSourcePayload, error) { - scope, err := r.authorize(ctx, input.AccessSourceID, probo.ActionAccessSourceDelete) +// DeleteAccessReviewSource is the resolver for the deleteAccessReviewSource field. +func (r *mutationResolver) DeleteAccessReviewSource(ctx context.Context, input types.DeleteAccessReviewSourceInput) (*types.DeleteAccessReviewSourcePayload, error) { + scope, err := r.authorize(ctx, input.AccessReviewSourceID, accessreview.ActionSourceDelete) if err != nil { return nil, err } - if err := r.accessReview.Sources(scope).Delete(ctx, input.AccessSourceID); err != nil { + if err := r.accessReview.DeleteSource(ctx, scope, input.AccessReviewSourceID); err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { return nil, gqlutils.NotFound(ctx, err) } @@ -591,23 +656,24 @@ func (r *mutationResolver) DeleteAccessSource(ctx context.Context, input types.D panic(fmt.Errorf("cannot delete access source: %w", err)) } - return &types.DeleteAccessSourcePayload{ - DeletedAccessSourceID: input.AccessSourceID, + return &types.DeleteAccessReviewSourcePayload{ + DeletedAccessReviewSourceID: input.AccessReviewSourceID, }, nil } -// ConfigureAccessSource is the resolver for the configureAccessSource field. -func (r *mutationResolver) ConfigureAccessSource(ctx context.Context, input types.ConfigureAccessSourceInput) (*types.ConfigureAccessSourcePayload, error) { - scope, err := r.authorize(ctx, input.AccessSourceID, probo.ActionAccessSourceUpdate) +// ConfigureAccessReviewSource is the resolver for the configureAccessReviewSource field. +func (r *mutationResolver) ConfigureAccessReviewSource(ctx context.Context, input types.ConfigureAccessReviewSourceInput) (*types.ConfigureAccessReviewSourcePayload, error) { + scope, err := r.authorize(ctx, input.AccessReviewSourceID, accessreview.ActionSourceUpdate) if err != nil { return nil, err } - source, err := r.accessReview.Sources(scope).ConfigureAccessSource( + source, err := r.accessReview.ConfigureAccessReviewSource( ctx, - accessreview.ConfigureAccessSourceRequest{ - AccessSourceID: input.AccessSourceID, - OrganizationSlug: input.OrganizationSlug, + scope, + accessreview.ConfigureAccessReviewSourceRequest{ + AccessReviewSourceID: input.AccessReviewSourceID, + OrganizationSlug: input.OrganizationSlug, }, ) if err != nil { @@ -618,14 +684,14 @@ func (r *mutationResolver) ConfigureAccessSource(ctx context.Context, input type panic(fmt.Errorf("cannot configure access source: %w", err)) } - return &types.ConfigureAccessSourcePayload{ - AccessSource: types.NewAccessSource(source), + return &types.ConfigureAccessReviewSourcePayload{ + AccessReviewSource: types.NewAccessReviewSource(source), }, nil } // CreateAccessReviewCampaign is the resolver for the createAccessReviewCampaign field. func (r *mutationResolver) CreateAccessReviewCampaign(ctx context.Context, input types.CreateAccessReviewCampaignInput) (*types.CreateAccessReviewCampaignPayload, error) { - scope, err := r.authorize(ctx, input.OrganizationID, probo.ActionAccessReviewCampaignCreate) + scope, err := r.authorize(ctx, input.OrganizationID, accessreview.ActionCampaignCreate) if err != nil { return nil, err } @@ -635,12 +701,12 @@ func (r *mutationResolver) CreateAccessReviewCampaign(ctx context.Context, input description = *input.Description } - campaign, err := r.accessReview.Campaigns(scope).Create(ctx, accessreview.CreateAccessReviewCampaignRequest{ - OrganizationID: input.OrganizationID, - Name: input.Name, - Description: description, - FrameworkControls: input.FrameworkControls, - AccessSourceIDs: input.AccessSourceIds, + campaign, err := r.accessReview.CreateCampaign(ctx, scope, accessreview.CreateAccessReviewCampaignRequest{ + OrganizationID: input.OrganizationID, + Name: input.Name, + Description: description, + FrameworkControls: input.FrameworkControls, + AccessReviewSourceIDs: input.AccessReviewSourceIds, }) if err != nil { panic(fmt.Errorf("cannot create access review campaign: %w", err)) @@ -653,7 +719,7 @@ func (r *mutationResolver) CreateAccessReviewCampaign(ctx context.Context, input // UpdateAccessReviewCampaign is the resolver for the updateAccessReviewCampaign field. func (r *mutationResolver) UpdateAccessReviewCampaign(ctx context.Context, input types.UpdateAccessReviewCampaignInput) (*types.UpdateAccessReviewCampaignPayload, error) { - scope, err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignUpdate) + scope, err := r.authorize(ctx, input.AccessReviewCampaignID, accessreview.ActionCampaignUpdate) if err != nil { return nil, err } @@ -675,7 +741,7 @@ func (r *mutationResolver) UpdateAccessReviewCampaign(ctx context.Context, input req.FrameworkControls = &controls } - campaign, err := r.accessReview.Campaigns(scope).Update(ctx, req) + campaign, err := r.accessReview.UpdateCampaign(ctx, scope, req) if err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { return nil, gqlutils.NotFound(ctx, err) @@ -691,12 +757,12 @@ func (r *mutationResolver) UpdateAccessReviewCampaign(ctx context.Context, input // DeleteAccessReviewCampaign is the resolver for the deleteAccessReviewCampaign field. func (r *mutationResolver) DeleteAccessReviewCampaign(ctx context.Context, input types.DeleteAccessReviewCampaignInput) (*types.DeleteAccessReviewCampaignPayload, error) { - scope, err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignDelete) + scope, err := r.authorize(ctx, input.AccessReviewCampaignID, accessreview.ActionCampaignDelete) if err != nil { return nil, err } - if err := r.accessReview.Campaigns(scope).Delete(ctx, input.AccessReviewCampaignID); err != nil { + if err := r.accessReview.DeleteCampaign(ctx, scope, input.AccessReviewCampaignID); err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { return nil, gqlutils.NotFound(ctx, err) } @@ -711,12 +777,12 @@ func (r *mutationResolver) DeleteAccessReviewCampaign(ctx context.Context, input // StartAccessReviewCampaign is the resolver for the startAccessReviewCampaign field. func (r *mutationResolver) StartAccessReviewCampaign(ctx context.Context, input types.StartAccessReviewCampaignInput) (*types.StartAccessReviewCampaignPayload, error) { - scope, err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignStart) + scope, err := r.authorize(ctx, input.AccessReviewCampaignID, accessreview.ActionCampaignStart) if err != nil { return nil, err } - campaign, err := r.accessReview.Campaigns(scope).Start(ctx, input.AccessReviewCampaignID) + campaign, err := r.accessReview.StartCampaign(ctx, scope, input.AccessReviewCampaignID) if err != nil { panic(fmt.Errorf("cannot start access review campaign: %w", err)) } @@ -728,12 +794,12 @@ func (r *mutationResolver) StartAccessReviewCampaign(ctx context.Context, input // CloseAccessReviewCampaign is the resolver for the closeAccessReviewCampaign field. func (r *mutationResolver) CloseAccessReviewCampaign(ctx context.Context, input types.CloseAccessReviewCampaignInput) (*types.CloseAccessReviewCampaignPayload, error) { - scope, err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignClose) + scope, err := r.authorize(ctx, input.AccessReviewCampaignID, accessreview.ActionCampaignClose) if err != nil { return nil, err } - campaign, err := r.accessReview.Campaigns(scope).Close(ctx, input.AccessReviewCampaignID) + campaign, err := r.accessReview.CloseCampaign(ctx, scope, input.AccessReviewCampaignID) if err != nil { panic(fmt.Errorf("cannot close access review campaign: %w", err)) } @@ -745,12 +811,12 @@ func (r *mutationResolver) CloseAccessReviewCampaign(ctx context.Context, input // CancelAccessReviewCampaign is the resolver for the cancelAccessReviewCampaign field. func (r *mutationResolver) CancelAccessReviewCampaign(ctx context.Context, input types.CancelAccessReviewCampaignInput) (*types.CancelAccessReviewCampaignPayload, error) { - scope, err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignCancel) + scope, err := r.authorize(ctx, input.AccessReviewCampaignID, accessreview.ActionCampaignCancel) if err != nil { return nil, err } - campaign, err := r.accessReview.Campaigns(scope).Cancel(ctx, input.AccessReviewCampaignID) + campaign, err := r.accessReview.CancelCampaign(ctx, scope, input.AccessReviewCampaignID) if err != nil { panic(fmt.Errorf("cannot cancel access review campaign: %w", err)) } @@ -760,49 +826,49 @@ func (r *mutationResolver) CancelAccessReviewCampaign(ctx context.Context, input }, nil } -// AddAccessReviewCampaignScopeSource is the resolver for the addAccessReviewCampaignScopeSource field. -func (r *mutationResolver) AddAccessReviewCampaignScopeSource(ctx context.Context, input types.AddAccessReviewCampaignScopeSourceInput) (*types.AddAccessReviewCampaignScopeSourcePayload, error) { - scope, err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignAddScopeSource) +// AddAccessReviewCampaignSource is the resolver for the addAccessReviewCampaignSource field. +func (r *mutationResolver) AddAccessReviewCampaignSource(ctx context.Context, input types.AddAccessReviewCampaignSourceInput) (*types.AddAccessReviewCampaignSourcePayload, error) { + scope, err := r.authorize(ctx, input.AccessReviewCampaignID, accessreview.ActionCampaignAddSource) if err != nil { return nil, err } - campaign, err := r.accessReview.Campaigns(scope).AddScopeSource(ctx, accessreview.AddCampaignScopeSourceRequest{ - CampaignID: input.AccessReviewCampaignID, - AccessSourceID: input.AccessSourceID, + campaign, err := r.accessReview.AddCampaignSource(ctx, scope, accessreview.AddCampaignSourceRequest{ + CampaignID: input.AccessReviewCampaignID, + AccessReviewSourceID: input.AccessReviewSourceID, }) if err != nil { panic(fmt.Errorf("cannot add scope source to access review campaign: %w", err)) } - return &types.AddAccessReviewCampaignScopeSourcePayload{ + return &types.AddAccessReviewCampaignSourcePayload{ AccessReviewCampaign: types.NewAccessReviewCampaign(campaign), }, nil } -// RemoveAccessReviewCampaignScopeSource is the resolver for the removeAccessReviewCampaignScopeSource field. -func (r *mutationResolver) RemoveAccessReviewCampaignScopeSource(ctx context.Context, input types.RemoveAccessReviewCampaignScopeSourceInput) (*types.RemoveAccessReviewCampaignScopeSourcePayload, error) { - scope, err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignRemoveScopeSource) +// RemoveAccessReviewCampaignSource is the resolver for the removeAccessReviewCampaignSource field. +func (r *mutationResolver) RemoveAccessReviewCampaignSource(ctx context.Context, input types.RemoveAccessReviewCampaignSourceInput) (*types.RemoveAccessReviewCampaignSourcePayload, error) { + scope, err := r.authorize(ctx, input.AccessReviewCampaignID, accessreview.ActionCampaignRemoveSource) if err != nil { return nil, err } - campaign, err := r.accessReview.Campaigns(scope).RemoveScopeSource(ctx, accessreview.RemoveCampaignScopeSourceRequest{ - CampaignID: input.AccessReviewCampaignID, - AccessSourceID: input.AccessSourceID, + campaign, err := r.accessReview.RemoveCampaignSource(ctx, scope, accessreview.RemoveCampaignSourceRequest{ + CampaignID: input.AccessReviewCampaignID, + AccessReviewSourceID: input.AccessReviewSourceID, }) if err != nil { panic(fmt.Errorf("cannot remove scope source from access review campaign: %w", err)) } - return &types.RemoveAccessReviewCampaignScopeSourcePayload{ + return &types.RemoveAccessReviewCampaignSourcePayload{ AccessReviewCampaign: types.NewAccessReviewCampaign(campaign), }, nil } -// RecordAccessEntryDecision is the resolver for the recordAccessEntryDecision field. -func (r *mutationResolver) RecordAccessEntryDecision(ctx context.Context, input types.RecordAccessEntryDecisionInput) (*types.RecordAccessEntryDecisionPayload, error) { - scope, err := r.authorize(ctx, input.AccessEntryID, probo.ActionAccessEntryDecide) +// RecordAccessReviewEntryDecision is the resolver for the recordAccessReviewEntryDecision field. +func (r *mutationResolver) RecordAccessReviewEntryDecision(ctx context.Context, input types.RecordAccessReviewEntryDecisionInput) (*types.RecordAccessReviewEntryDecisionPayload, error) { + scope, err := r.authorize(ctx, input.AccessReviewEntryID, accessreview.ActionEntryDecide) if err != nil { return nil, err } @@ -815,13 +881,13 @@ func (r *mutationResolver) RecordAccessEntryDecision(ctx context.Context, input return nil, fmt.Errorf("no identity in context") } - req := accessreview.RecordAccessEntryDecisionRequest{ - EntryID: input.AccessEntryID, + req := accessreview.RecordAccessReviewEntryDecisionRequest{ + EntryID: input.AccessReviewEntryID, Decision: input.Decision, DecisionNote: input.DecisionNote, } - organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, input.AccessEntryID) + organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, input.AccessReviewEntryID) if err == nil { profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, organizationID) if err == nil { @@ -829,7 +895,7 @@ func (r *mutationResolver) RecordAccessEntryDecision(ctx context.Context, input } } - entry, err := r.accessReview.Entries(scope).RecordDecision(ctx, req) + entry, err := r.accessReview.RecordDecision(ctx, scope, req) if err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { return nil, gqlutils.NotFound(ctx, err) @@ -838,16 +904,16 @@ func (r *mutationResolver) RecordAccessEntryDecision(ctx context.Context, input panic(fmt.Errorf("cannot record access entry decision: %w", err)) } - return &types.RecordAccessEntryDecisionPayload{ - AccessEntry: types.NewAccessEntry(entry), + return &types.RecordAccessReviewEntryDecisionPayload{ + AccessReviewEntry: types.NewAccessReviewEntry(entry), }, nil } -// RecordAccessEntryDecisions is the resolver for the recordAccessEntryDecisions field. -func (r *mutationResolver) RecordAccessEntryDecisions(ctx context.Context, input types.RecordAccessEntryDecisionsInput) (*types.RecordAccessEntryDecisionsPayload, error) { +// RecordAccessReviewEntryDecisions is the resolver for the recordAccessReviewEntryDecisions field. +func (r *mutationResolver) RecordAccessReviewEntryDecisions(ctx context.Context, input types.RecordAccessReviewEntryDecisionsInput) (*types.RecordAccessReviewEntryDecisionsPayload, error) { if len(input.Decisions) == 0 { - return &types.RecordAccessEntryDecisionsPayload{ - AccessEntries: []*types.AccessEntry{}, + return &types.RecordAccessReviewEntryDecisionsPayload{ + AccessReviewEntries: []*types.AccessReviewEntry{}, }, nil } @@ -858,7 +924,7 @@ func (r *mutationResolver) RecordAccessEntryDecisions(ctx context.Context, input // Authorize each entry individually to prevent cross-org bypass. for _, d := range input.Decisions { - _, err := r.authorize(ctx, d.AccessEntryID, probo.ActionAccessEntryDecide) + _, err := r.authorize(ctx, d.AccessReviewEntryID, accessreview.ActionEntryDecide) if err != nil { return nil, err } @@ -869,18 +935,18 @@ func (r *mutationResolver) RecordAccessEntryDecisions(ctx context.Context, input return nil, fmt.Errorf("no identity in context") } - tenantID := input.Decisions[0].AccessEntryID.TenantID() + tenantID := input.Decisions[0].AccessReviewEntryID.TenantID() scope := coredata.NewScope(tenantID) // Cache profile lookups per organization so we resolve the correct // decidedByID for each entry even when a batch spans multiple orgs. profileCache := make(map[gid.GID]*gid.GID) - decisions := make([]accessreview.RecordAccessEntryDecisionRequest, len(input.Decisions)) + decisions := make([]accessreview.RecordAccessReviewEntryDecisionRequest, len(input.Decisions)) for i, d := range input.Decisions { var decidedByID *gid.GID - organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, d.AccessEntryID) + organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, d.AccessReviewEntryID) if err == nil { if cached, ok := profileCache[organizationID]; ok { decidedByID = cached @@ -894,15 +960,15 @@ func (r *mutationResolver) RecordAccessEntryDecisions(ctx context.Context, input } } - decisions[i] = accessreview.RecordAccessEntryDecisionRequest{ - EntryID: d.AccessEntryID, + decisions[i] = accessreview.RecordAccessReviewEntryDecisionRequest{ + EntryID: d.AccessReviewEntryID, Decision: d.Decision, DecisionNote: d.DecisionNote, DecidedByID: decidedByID, } } - entries, err := r.accessReview.Entries(scope).RecordDecisions(ctx, decisions) + entries, err := r.accessReview.RecordDecisions(ctx, scope, decisions) if err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { return nil, gqlutils.NotFound(ctx, err) @@ -911,25 +977,25 @@ func (r *mutationResolver) RecordAccessEntryDecisions(ctx context.Context, input panic(fmt.Errorf("cannot record access entry decisions: %w", err)) } - accessEntries := make([]*types.AccessEntry, len(entries)) + accessEntries := make([]*types.AccessReviewEntry, len(entries)) for i, e := range entries { - accessEntries[i] = types.NewAccessEntry(e) + accessEntries[i] = types.NewAccessReviewEntry(e) } - return &types.RecordAccessEntryDecisionsPayload{ - AccessEntries: accessEntries, + return &types.RecordAccessReviewEntryDecisionsPayload{ + AccessReviewEntries: accessEntries, }, nil } -// FlagAccessEntry is the resolver for the flagAccessEntry field. -func (r *mutationResolver) FlagAccessEntry(ctx context.Context, input types.FlagAccessEntryInput) (*types.FlagAccessEntryPayload, error) { - scope, err := r.authorize(ctx, input.AccessEntryID, probo.ActionAccessEntryFlag) +// FlagAccessReviewEntry is the resolver for the flagAccessReviewEntry field. +func (r *mutationResolver) FlagAccessReviewEntry(ctx context.Context, input types.FlagAccessReviewEntryInput) (*types.FlagAccessReviewEntryPayload, error) { + scope, err := r.authorize(ctx, input.AccessReviewEntryID, accessreview.ActionEntryFlag) if err != nil { return nil, err } - entry, err := r.accessReview.Entries(scope).FlagEntry(ctx, accessreview.FlagAccessEntryRequest{ - EntryID: input.AccessEntryID, + entry, err := r.accessReview.FlagEntry(ctx, scope, accessreview.FlagAccessReviewEntryRequest{ + EntryID: input.AccessReviewEntryID, Flags: input.Flags, FlagReasons: input.FlagReasons, }) @@ -941,19 +1007,11 @@ func (r *mutationResolver) FlagAccessEntry(ctx context.Context, input types.Flag panic(fmt.Errorf("cannot flag access entry: %w", err)) } - return &types.FlagAccessEntryPayload{ - AccessEntry: types.NewAccessEntry(entry), + return &types.FlagAccessReviewEntryPayload{ + AccessReviewEntry: types.NewAccessReviewEntry(entry), }, nil } -// AccessEntry returns schema.AccessEntryResolver implementation. -func (r *Resolver) AccessEntry() schema.AccessEntryResolver { return &accessEntryResolver{r} } - -// AccessEntryConnection returns schema.AccessEntryConnectionResolver implementation. -func (r *Resolver) AccessEntryConnection() schema.AccessEntryConnectionResolver { - return &accessEntryConnectionResolver{r} -} - // AccessReviewCampaign returns schema.AccessReviewCampaignResolver implementation. func (r *Resolver) AccessReviewCampaign() schema.AccessReviewCampaignResolver { return &accessReviewCampaignResolver{r} @@ -964,23 +1022,35 @@ func (r *Resolver) AccessReviewCampaignConnection() schema.AccessReviewCampaignC return &accessReviewCampaignConnectionResolver{r} } -// AccessReviewCampaignScopeSource returns schema.AccessReviewCampaignScopeSourceResolver implementation. -func (r *Resolver) AccessReviewCampaignScopeSource() schema.AccessReviewCampaignScopeSourceResolver { - return &accessReviewCampaignScopeSourceResolver{r} +// AccessReviewCampaignSource returns schema.AccessReviewCampaignSourceResolver implementation. +func (r *Resolver) AccessReviewCampaignSource() schema.AccessReviewCampaignSourceResolver { + return &accessReviewCampaignSourceResolver{r} } -// AccessSource returns schema.AccessSourceResolver implementation. -func (r *Resolver) AccessSource() schema.AccessSourceResolver { return &accessSourceResolver{r} } - -// AccessSourceConnection returns schema.AccessSourceConnectionResolver implementation. -func (r *Resolver) AccessSourceConnection() schema.AccessSourceConnectionResolver { - return &accessSourceConnectionResolver{r} +// AccessReviewEntry returns schema.AccessReviewEntryResolver implementation. +func (r *Resolver) AccessReviewEntry() schema.AccessReviewEntryResolver { + return &accessReviewEntryResolver{r} +} + +// AccessReviewEntryConnection returns schema.AccessReviewEntryConnectionResolver implementation. +func (r *Resolver) AccessReviewEntryConnection() schema.AccessReviewEntryConnectionResolver { + return &accessReviewEntryConnectionResolver{r} +} + +// AccessReviewSource returns schema.AccessReviewSourceResolver implementation. +func (r *Resolver) AccessReviewSource() schema.AccessReviewSourceResolver { + return &accessReviewSourceResolver{r} +} + +// AccessReviewSourceConnection returns schema.AccessReviewSourceConnectionResolver implementation. +func (r *Resolver) AccessReviewSourceConnection() schema.AccessReviewSourceConnectionResolver { + return &accessReviewSourceConnectionResolver{r} } -type accessEntryResolver struct{ *Resolver } -type accessEntryConnectionResolver struct{ *Resolver } type accessReviewCampaignResolver struct{ *Resolver } type accessReviewCampaignConnectionResolver struct{ *Resolver } -type accessReviewCampaignScopeSourceResolver struct{ *Resolver } -type accessSourceResolver struct{ *Resolver } -type accessSourceConnectionResolver struct{ *Resolver } +type accessReviewCampaignSourceResolver struct{ *Resolver } +type accessReviewEntryResolver struct{ *Resolver } +type accessReviewEntryConnectionResolver struct{ *Resolver } +type accessReviewSourceResolver struct{ *Resolver } +type accessReviewSourceConnectionResolver struct{ *Resolver } diff --git a/pkg/server/api/console/v1/access_source_provider_config.go b/pkg/server/api/console/v1/access_source_provider_config.go index 2fcbd2851..411de4ac6 100644 --- a/pkg/server/api/console/v1/access_source_provider_config.go +++ b/pkg/server/api/console/v1/access_source_provider_config.go @@ -40,7 +40,7 @@ type providerOrgConfig struct { } // providerOrgConfigs is the single source of truth that the three -// AccessSource picker resolvers (ProviderOrganizations, +// AccessReviewSource picker resolvers (ProviderOrganizations, // SelectedOrganization, NeedsConfiguration) dispatch through. Adding a // provider takes one entry here, not three switch arms. var providerOrgConfigs = map[coredata.ConnectorProvider]providerOrgConfig{ diff --git a/pkg/server/api/console/v1/base_resolvers.go b/pkg/server/api/console/v1/base_resolvers.go index 639f22395..014f2b6ae 100644 --- a/pkg/server/api/console/v1/base_resolvers.go +++ b/pkg/server/api/console/v1/base_resolvers.go @@ -12,6 +12,7 @@ import ( "strings" "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview" "go.probo.inc/probo/pkg/agentrun" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" @@ -384,34 +385,34 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error return types.NewAgentRun(run), nil } case coredata.AccessReviewCampaignEntityType: - action = probo.ActionAccessReviewCampaignGet + action = accessreview.ActionCampaignGet loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) { - campaign, err := r.accessReview.Campaigns(scope).Get(ctx, id) + campaign, err := r.accessReview.GetCampaign(ctx, scope, id) if err != nil { return nil, err } return types.NewAccessReviewCampaign(campaign), nil } - case coredata.AccessSourceEntityType: - action = probo.ActionAccessSourceGet + case coredata.AccessReviewSourceEntityType: + action = accessreview.ActionSourceGet loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) { - source, err := r.accessReview.Sources(scope).Get(ctx, id) + source, err := r.accessReview.GetSource(ctx, scope, id) if err != nil { return nil, err } - return types.NewAccessSource(source), nil + return types.NewAccessReviewSource(source), nil } - case coredata.AccessEntryEntityType: - action = probo.ActionAccessEntryGet + case coredata.AccessReviewEntryEntityType: + action = accessreview.ActionEntryGet loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) { - entry, err := r.accessReview.Entries(scope).Get(ctx, id) + entry, err := r.accessReview.GetEntry(ctx, scope, id) if err != nil { return nil, err } - return types.NewAccessEntry(entry), nil + return types.NewAccessReviewEntry(entry), nil } case coredata.CookieBannerEntityType: action = probo.ActionCookieBannerGet diff --git a/pkg/server/api/console/v1/graphql/access_review_campaign.graphql b/pkg/server/api/console/v1/graphql/access_review_campaign.graphql index 12b3b19fc..e2555d7e2 100644 --- a/pkg/server/api/console/v1/graphql/access_review_campaign.graphql +++ b/pkg/server/api/console/v1/graphql/access_review_campaign.graphql @@ -24,25 +24,25 @@ enum AccessReviewCampaignStatus ) } -enum AccessSourceCategory +enum AccessReviewSourceCategory @goModel( - model: "go.probo.inc/probo/pkg/coredata.AccessSourceCategory" + model: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategory" ) { SAAS @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessSourceCategorySaaS" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategorySaaS" ) CLOUD_INFRA @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessSourceCategoryCloudInfra" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategoryCloudInfra" ) SOURCE_CODE @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessSourceCategorySourceCode" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategorySourceCode" ) OTHER @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessSourceCategoryOther" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategoryOther" ) } @@ -68,109 +68,109 @@ enum AccessReviewCampaignSourceFetchStatus ) } -enum AccessEntryFlag - @goModel(model: "go.probo.inc/probo/pkg/coredata.AccessEntryFlag") { +enum AccessReviewEntryFlag + @goModel(model: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlag") { NONE @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagNone" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagNone" ) ORPHANED @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagOrphaned" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagOrphaned" ) INACTIVE @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagInactive" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagInactive" ) EXCESSIVE @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagExcessive" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagExcessive" ) ROLE_MISMATCH @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagRoleMismatch" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagRoleMismatch" ) NEW @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagNew" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagNew" ) DORMANT @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagDormant" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagDormant" ) TERMINATED_USER @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagTerminatedUser" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagTerminatedUser" ) CONTRACTOR_EXPIRED @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagContractorExpired" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagContractorExpired" ) SOD_CONFLICT @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagSoDConflict" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagSoDConflict" ) PRIVILEGED_ACCESS @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagPrivilegedAccess" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagPrivilegedAccess" ) ROLE_CREEP @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagRoleCreep" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagRoleCreep" ) NO_BUSINESS_JUSTIFICATION @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagNoBusinessJustification" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagNoBusinessJustification" ) OUT_OF_DEPARTMENT @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagOutOfDepartment" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagOutOfDepartment" ) SHARED_ACCOUNT @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagSharedAccount" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagSharedAccount" ) } -enum AccessEntryDecision +enum AccessReviewEntryDecision @goModel( - model: "go.probo.inc/probo/pkg/coredata.AccessEntryDecision" + model: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryDecision" ) { PENDING @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryDecisionPending" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryDecisionPending" ) APPROVED @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryDecisionApproved" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryDecisionApproved" ) REVOKE @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryDecisionRevoke" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryDecisionRevoke" ) DEFER @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryDecisionDefer" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryDecisionDefer" ) ESCALATE @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryDecisionEscalate" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryDecisionEscalate" ) } -enum AccessEntryIncrementalTag - @goModel(model: "go.probo.inc/probo/pkg/coredata.AccessEntryIncrementalTag") { +enum AccessReviewEntryIncrementalTag + @goModel(model: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryIncrementalTag") { NEW @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryIncrementalTagNew" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryIncrementalTagNew" ) REMOVED @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryIncrementalTagRemoved" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryIncrementalTagRemoved" ) UNCHANGED @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryIncrementalTagUnchanged" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryIncrementalTagUnchanged" ) } @@ -190,49 +190,49 @@ enum MfaStatus ) } -enum AccessEntryAuthMethod +enum AccessReviewEntryAuthMethod @goModel( - model: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethod" + model: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAuthMethod" ) { SSO @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethodSSO" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAuthMethodSSO" ) PASSWORD @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethodPassword" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAuthMethodPassword" ) API_KEY @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethodAPIKey" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAuthMethodAPIKey" ) SERVICE_ACCOUNT @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethodServiceAccount" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAuthMethodServiceAccount" ) UNKNOWN @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethodUnknown" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAuthMethodUnknown" ) } -enum AccessEntryAccountType +enum AccessReviewEntryAccountType @goModel( - model: "go.probo.inc/probo/pkg/coredata.AccessEntryAccountType" + model: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAccountType" ) { USER @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryAccountTypeUser" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAccountTypeUser" ) SERVICE_ACCOUNT @goEnum( - value: "go.probo.inc/probo/pkg/coredata.AccessEntryAccountTypeServiceAccount" + value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAccountTypeServiceAccount" ) } -enum AccessSourceOrderField +enum AccessReviewSourceOrderField @goModel( - model: "go.probo.inc/probo/pkg/coredata.AccessSourceOrderField" + model: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceOrderField" ) { CREATED_AT } @@ -244,22 +244,22 @@ enum AccessReviewCampaignOrderField CREATED_AT } -enum AccessEntryOrderField +enum AccessReviewEntryOrderField @goModel( - model: "go.probo.inc/probo/pkg/coredata.AccessEntryOrderField" + model: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryOrderField" ) { CREATED_AT } -enum AccessSourceConnectionStatus { +enum AccessReviewSourceConnectionStatus { CONNECTED DISCONNECTED NOT_APPLICABLE } -input AccessSourceOrder { +input AccessReviewSourceOrder { direction: OrderDirection! - field: AccessSourceOrderField! + field: AccessReviewSourceOrderField! } input AccessReviewCampaignOrder { @@ -267,22 +267,22 @@ input AccessReviewCampaignOrder { field: AccessReviewCampaignOrderField! } -input AccessEntryOrder { +input AccessReviewEntryOrder { direction: OrderDirection! - field: AccessEntryOrderField! + field: AccessReviewEntryOrderField! } -input AccessEntryFilter +input AccessReviewEntryFilter @goModel( - model: "go.probo.inc/probo/pkg/coredata.AccessEntryFilter" + model: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFilter" ) { - decision: AccessEntryDecision - flag: AccessEntryFlag - incrementalTag: AccessEntryIncrementalTag + decision: AccessReviewEntryDecision + flag: AccessReviewEntryFlag + incrementalTag: AccessReviewEntryIncrementalTag isAdmin: Boolean active: Boolean - authMethod: AccessEntryAuthMethod - accountType: AccessEntryAccountType + authMethod: AccessReviewEntryAuthMethod + accountType: AccessReviewEntryAccountType } type ProviderOrganization { @@ -290,7 +290,7 @@ type ProviderOrganization { displayName: String! } -type AccessSource implements Node { +type AccessReviewSource implements Node { id: ID! organization: Organization! @goField(forceResolver: true) connectorId: ID @@ -299,7 +299,7 @@ type AccessSource implements Node { csvData: String providerOrganizations: [ProviderOrganization!]! @goField(forceResolver: true) needsConfiguration: Boolean! @goField(forceResolver: true) - connectionStatus: AccessSourceConnectionStatus! @goField(forceResolver: true) + connectionStatus: AccessReviewSourceConnectionStatus! @goField(forceResolver: true) selectedOrganization: String @goField(forceResolver: true) createdAt: Datetime! updatedAt: Datetime! @@ -307,30 +307,51 @@ type AccessSource implements Node { permission(action: String!): Boolean! @goField(forceResolver: true) } -type AccessReviewCampaignScopeSource +type AccessReviewCampaignSourceFetchAttempt @goModel( - model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewCampaignScopeSource" + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewCampaignSourceFetchAttempt" ) { id: ID! - source: AccessSource! + attemptNumber: Int! + status: AccessReviewCampaignSourceFetchStatus! + fetchedAccountsCount: Int! + error: String + startedAt: Datetime + completedAt: Datetime + createdAt: Datetime! + updatedAt: Datetime! +} + +type AccessReviewCampaignSource + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewCampaignSource" + ) { + id: ID! + "The live access source this snapshot was taken from. Null once the source is deleted." + source: AccessReviewSource @goField(forceResolver: true) name: String! + "Current fetch status, derived from the latest fetch attempt." fetchStatus: AccessReviewCampaignSourceFetchStatus! fetchedAccountsCount: Int! attemptCount: Int! + "Generic, user-facing error from the latest attempt. Raw errors stay in logs." lastError: String fetchStartedAt: Datetime fetchCompletedAt: Datetime + "Full append-only history of fetch attempts, most recent first." + fetchAttempts: [AccessReviewCampaignSourceFetchAttempt!]! @goField(forceResolver: true) + entries( first: Int after: CursorKey last: Int before: CursorKey - orderBy: AccessEntryOrder - filter: AccessEntryFilter - ): AccessEntryConnection! @goField(forceResolver: true) + orderBy: AccessReviewEntryOrder + filter: AccessReviewEntryFilter + ): AccessReviewEntryConnection! @goField(forceResolver: true) - statistics: AccessReviewCampaignStatistics! @goField(forceResolver: true) + statistics: AccessReviewStatistics! @goField(forceResolver: true) } type AccessReviewCampaign implements Node { @@ -345,29 +366,30 @@ type AccessReviewCampaign implements Node { createdAt: Datetime! updatedAt: Datetime! - scopeSources: [AccessReviewCampaignScopeSource!]! @goField(forceResolver: true) + sources: [AccessReviewCampaignSource!]! @goField(forceResolver: true) entries( first: Int after: CursorKey last: Int before: CursorKey - orderBy: AccessEntryOrder - accessSourceId: ID - filter: AccessEntryFilter - ): AccessEntryConnection! @goField(forceResolver: true) + orderBy: AccessReviewEntryOrder + campaignSourceId: ID + filter: AccessReviewEntryFilter + ): AccessReviewEntryConnection! @goField(forceResolver: true) pendingEntryCount: Int! @goField(forceResolver: true) - statistics: AccessReviewCampaignStatistics! @goField(forceResolver: true) + statistics: AccessReviewStatistics! @goField(forceResolver: true) permission(action: String!): Boolean! @goField(forceResolver: true) } -type AccessEntry implements Node { +type AccessReviewEntry implements Node { id: ID! campaign: AccessReviewCampaign! @goField(forceResolver: true) - accessSource: AccessSource! @goField(forceResolver: true) + "The live access source this entry came from. Null once the source is deleted." + accessReviewSource: AccessReviewSource @goField(forceResolver: true) email: String! fullName: String! role: String! @@ -375,19 +397,19 @@ type AccessEntry implements Node { isAdmin: Boolean! active: Boolean mfaStatus: MfaStatus! - authMethod: AccessEntryAuthMethod! - accountType: AccessEntryAccountType! + authMethod: AccessReviewEntryAuthMethod! + accountType: AccessReviewEntryAccountType! lastLogin: Datetime accountCreatedAt: Datetime externalId: String! - incrementalTag: AccessEntryIncrementalTag! - flags: [AccessEntryFlag!]! + incrementalTag: AccessReviewEntryIncrementalTag! + flags: [AccessReviewEntryFlag!]! flagReasons: [String!]! - decision: AccessEntryDecision! + decision: AccessReviewEntryDecision! decisionNote: String decidedBy: ID decidedAt: Datetime - decisionHistory: [AccessEntryDecisionHistoryEntry!]! + decisionHistory: [AccessReviewEntryDecisionHistoryEntry!]! @goField(forceResolver: true) createdAt: Datetime! updatedAt: Datetime! @@ -395,27 +417,27 @@ type AccessEntry implements Node { permission(action: String!): Boolean! @goField(forceResolver: true) } -type AccessEntryDecisionHistoryEntry { +type AccessReviewEntryDecisionHistoryEntry { id: ID! - decision: AccessEntryDecision! + decision: AccessReviewEntryDecision! decisionNote: String decidedBy: ID decidedAt: Datetime! createdAt: Datetime! } -type AccessSourceConnection +type AccessReviewSourceConnection @goModel( - model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessSourceConnection" + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewSourceConnection" ) { totalCount: Int! @goField(forceResolver: true) - edges: [AccessSourceEdge!]! + edges: [AccessReviewSourceEdge!]! pageInfo: PageInfo! } -type AccessSourceEdge { +type AccessReviewSourceEdge { cursor: CursorKey! - node: AccessSource! + node: AccessReviewSource! } type AccessReviewCampaignConnection @@ -432,55 +454,55 @@ type AccessReviewCampaignEdge { node: AccessReviewCampaign! } -type AccessEntryConnection +type AccessReviewEntryConnection @goModel( - model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessEntryConnection" + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewEntryConnection" ) { totalCount: Int! @goField(forceResolver: true) - edges: [AccessEntryEdge!]! + edges: [AccessReviewEntryEdge!]! pageInfo: PageInfo! } -type AccessEntryEdge { +type AccessReviewEntryEdge { cursor: CursorKey! - node: AccessEntry! + node: AccessReviewEntry! } -type AccessReviewCampaignStatistics { +type AccessReviewStatistics { totalCount: Int! - decisionCounts: [AccessEntryDecisionCount!]! - flagCounts: [AccessEntryFlagCount!]! - incrementalTagCounts: [AccessEntryIncrementalTagCount!]! + decisionCounts: [AccessReviewEntryDecisionCount!]! + flagCounts: [AccessReviewEntryFlagCount!]! + incrementalTagCounts: [AccessReviewEntryIncrementalTagCount!]! } -type AccessEntryDecisionCount { - decision: AccessEntryDecision! +type AccessReviewEntryDecisionCount { + decision: AccessReviewEntryDecision! count: Int! } -type AccessEntryFlagCount { - flag: AccessEntryFlag! +type AccessReviewEntryFlagCount { + flag: AccessReviewEntryFlag! count: Int! } -type AccessEntryIncrementalTagCount { - incrementalTag: AccessEntryIncrementalTag! +type AccessReviewEntryIncrementalTagCount { + incrementalTag: AccessReviewEntryIncrementalTag! count: Int! } extend type Mutation { - createAccessSource( - input: CreateAccessSourceInput! - ): CreateAccessSourcePayload! - updateAccessSource( - input: UpdateAccessSourceInput! - ): UpdateAccessSourcePayload! - deleteAccessSource( - input: DeleteAccessSourceInput! - ): DeleteAccessSourcePayload! - configureAccessSource( - input: ConfigureAccessSourceInput! - ): ConfigureAccessSourcePayload! + createAccessReviewSource( + input: CreateAccessReviewSourceInput! + ): CreateAccessReviewSourcePayload! + updateAccessReviewSource( + input: UpdateAccessReviewSourceInput! + ): UpdateAccessReviewSourcePayload! + deleteAccessReviewSource( + input: DeleteAccessReviewSourceInput! + ): DeleteAccessReviewSourcePayload! + configureAccessReviewSource( + input: ConfigureAccessReviewSourceInput! + ): ConfigureAccessReviewSourcePayload! createAccessReviewCampaign( input: CreateAccessReviewCampaignInput! ): CreateAccessReviewCampaignPayload! @@ -499,60 +521,60 @@ extend type Mutation { cancelAccessReviewCampaign( input: CancelAccessReviewCampaignInput! ): CancelAccessReviewCampaignPayload! - addAccessReviewCampaignScopeSource( - input: AddAccessReviewCampaignScopeSourceInput! - ): AddAccessReviewCampaignScopeSourcePayload! - removeAccessReviewCampaignScopeSource( - input: RemoveAccessReviewCampaignScopeSourceInput! - ): RemoveAccessReviewCampaignScopeSourcePayload! - recordAccessEntryDecision( - input: RecordAccessEntryDecisionInput! - ): RecordAccessEntryDecisionPayload! - recordAccessEntryDecisions( - input: RecordAccessEntryDecisionsInput! - ): RecordAccessEntryDecisionsPayload! - flagAccessEntry( - input: FlagAccessEntryInput! - ): FlagAccessEntryPayload! + addAccessReviewCampaignSource( + input: AddAccessReviewCampaignSourceInput! + ): AddAccessReviewCampaignSourcePayload! + removeAccessReviewCampaignSource( + input: RemoveAccessReviewCampaignSourceInput! + ): RemoveAccessReviewCampaignSourcePayload! + recordAccessReviewEntryDecision( + input: RecordAccessReviewEntryDecisionInput! + ): RecordAccessReviewEntryDecisionPayload! + recordAccessReviewEntryDecisions( + input: RecordAccessReviewEntryDecisionsInput! + ): RecordAccessReviewEntryDecisionsPayload! + flagAccessReviewEntry( + input: FlagAccessReviewEntryInput! + ): FlagAccessReviewEntryPayload! } -input CreateAccessSourceInput { +input CreateAccessReviewSourceInput { organizationId: ID! connectorId: ID name: String! csvData: String } -type CreateAccessSourcePayload { - accessSourceEdge: AccessSourceEdge! +type CreateAccessReviewSourcePayload { + accessReviewSourceEdge: AccessReviewSourceEdge! } -input UpdateAccessSourceInput { - accessSourceId: ID! +input UpdateAccessReviewSourceInput { + accessReviewSourceId: ID! name: String @goField(omittable: true) connectorId: ID @goField(omittable: true) csvData: String @goField(omittable: true) } -type UpdateAccessSourcePayload { - accessSource: AccessSource! +type UpdateAccessReviewSourcePayload { + accessReviewSource: AccessReviewSource! } -input DeleteAccessSourceInput { - accessSourceId: ID! +input DeleteAccessReviewSourceInput { + accessReviewSourceId: ID! } -type DeleteAccessSourcePayload { - deletedAccessSourceId: ID! +type DeleteAccessReviewSourcePayload { + deletedAccessReviewSourceId: ID! } -input ConfigureAccessSourceInput { - accessSourceId: ID! +input ConfigureAccessReviewSourceInput { + accessReviewSourceId: ID! organizationSlug: String! } -type ConfigureAccessSourcePayload { - accessSource: AccessSource! +type ConfigureAccessReviewSourcePayload { + accessReviewSource: AccessReviewSource! } input CreateAccessReviewCampaignInput { @@ -560,7 +582,7 @@ input CreateAccessReviewCampaignInput { name: String! description: String frameworkControls: [String!] - accessSourceIds: [ID!] + accessReviewSourceIds: [ID!] } type CreateAccessReviewCampaignPayload { @@ -610,54 +632,54 @@ type CancelAccessReviewCampaignPayload { accessReviewCampaign: AccessReviewCampaign! } -input AddAccessReviewCampaignScopeSourceInput { +input AddAccessReviewCampaignSourceInput { accessReviewCampaignId: ID! - accessSourceId: ID! + accessReviewSourceId: ID! } -type AddAccessReviewCampaignScopeSourcePayload { +type AddAccessReviewCampaignSourcePayload { accessReviewCampaign: AccessReviewCampaign! } -input RemoveAccessReviewCampaignScopeSourceInput { +input RemoveAccessReviewCampaignSourceInput { accessReviewCampaignId: ID! - accessSourceId: ID! + accessReviewSourceId: ID! } -type RemoveAccessReviewCampaignScopeSourcePayload { +type RemoveAccessReviewCampaignSourcePayload { accessReviewCampaign: AccessReviewCampaign! } -input RecordAccessEntryDecisionInput { - accessEntryId: ID! - decision: AccessEntryDecision! +input RecordAccessReviewEntryDecisionInput { + accessReviewEntryId: ID! + decision: AccessReviewEntryDecision! decisionNote: String } -type RecordAccessEntryDecisionPayload { - accessEntry: AccessEntry! +type RecordAccessReviewEntryDecisionPayload { + accessReviewEntry: AccessReviewEntry! } -input RecordAccessEntryDecisionsInput { - decisions: [AccessEntryDecisionInput!]! +input RecordAccessReviewEntryDecisionsInput { + decisions: [AccessReviewEntryDecisionInput!]! } -input AccessEntryDecisionInput { - accessEntryId: ID! - decision: AccessEntryDecision! +input AccessReviewEntryDecisionInput { + accessReviewEntryId: ID! + decision: AccessReviewEntryDecision! decisionNote: String } -type RecordAccessEntryDecisionsPayload { - accessEntries: [AccessEntry!]! +type RecordAccessReviewEntryDecisionsPayload { + accessReviewEntries: [AccessReviewEntry!]! } -input FlagAccessEntryInput { - accessEntryId: ID! - flags: [AccessEntryFlag!]! +input FlagAccessReviewEntryInput { + accessReviewEntryId: ID! + flags: [AccessReviewEntryFlag!]! flagReasons: [String!] } -type FlagAccessEntryPayload { - accessEntry: AccessEntry! +type FlagAccessReviewEntryPayload { + accessReviewEntry: AccessReviewEntry! } diff --git a/pkg/server/api/console/v1/graphql/organization.graphql b/pkg/server/api/console/v1/graphql/organization.graphql index 3d9dfe2a2..4f57bd8a4 100644 --- a/pkg/server/api/console/v1/graphql/organization.graphql +++ b/pkg/server/api/console/v1/graphql/organization.graphql @@ -110,13 +110,13 @@ type Organization implements Node { measureCategories: [String!]! @goField(forceResolver: true) - accessSources( + accessReviewSources( first: Int after: CursorKey last: Int before: CursorKey - orderBy: AccessSourceOrder - ): AccessSourceConnection! @goField(forceResolver: true) + orderBy: AccessReviewSourceOrder + ): AccessReviewSourceConnection! @goField(forceResolver: true) accessReviewCampaigns( first: Int diff --git a/pkg/server/api/console/v1/organization_resolvers.go b/pkg/server/api/console/v1/organization_resolvers.go index f9471a930..47ac2802d 100644 --- a/pkg/server/api/console/v1/organization_resolvers.go +++ b/pkg/server/api/console/v1/organization_resolvers.go @@ -11,6 +11,7 @@ import ( "fmt" "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview" "go.probo.inc/probo/pkg/agentrun" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" @@ -157,20 +158,20 @@ func (r *organizationResolver) MeasureCategories(ctx context.Context, obj *types return categories, nil } -// AccessSources is the resolver for the accessSources field. -func (r *organizationResolver) AccessSources(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessSourceOrder) (*types.AccessSourceConnection, error) { - scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceList) +// AccessReviewSources is the resolver for the accessSources field. +func (r *organizationResolver) AccessReviewSources(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessReviewSourceOrder) (*types.AccessReviewSourceConnection, error) { + scope, err := r.authorize(ctx, obj.ID, accessreview.ActionSourceList) if err != nil { return nil, err } - pageOrderBy := page.OrderBy[coredata.AccessSourceOrderField]{ - Field: coredata.AccessSourceOrderFieldCreatedAt, + pageOrderBy := page.OrderBy[coredata.AccessReviewSourceOrderField]{ + Field: coredata.AccessReviewSourceOrderFieldCreatedAt, Direction: page.OrderDirectionDesc, } if orderBy != nil { - pageOrderBy = page.OrderBy[coredata.AccessSourceOrderField]{ + pageOrderBy = page.OrderBy[coredata.AccessReviewSourceOrderField]{ Field: orderBy.Field, Direction: orderBy.Direction, } @@ -178,17 +179,17 @@ func (r *organizationResolver) AccessSources(ctx context.Context, obj *types.Org cursor := types.NewCursor(first, after, last, before, pageOrderBy) - p, err := r.accessReview.Sources(scope).ListForOrganizationID(ctx, obj.ID, cursor) + p, err := r.accessReview.ListSourcesForOrganizationID(ctx, scope, obj.ID, cursor) if err != nil { panic(fmt.Errorf("cannot list access sources: %w", err)) } - return types.NewAccessSourceConnection(p, r, obj.ID), nil + return types.NewAccessReviewSourceConnection(p, r, obj.ID), nil } // AccessReviewCampaigns is the resolver for the accessReviewCampaigns field. func (r *organizationResolver) AccessReviewCampaigns(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessReviewCampaignOrder) (*types.AccessReviewCampaignConnection, error) { - scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessReviewCampaignList) + scope, err := r.authorize(ctx, obj.ID, accessreview.ActionCampaignList) if err != nil { return nil, err } @@ -207,7 +208,7 @@ func (r *organizationResolver) AccessReviewCampaigns(ctx context.Context, obj *t cursor := types.NewCursor(first, after, last, before, pageOrderBy) - p, err := r.accessReview.Campaigns(scope).ListForOrganizationID(ctx, obj.ID, cursor) + p, err := r.accessReview.ListCampaignsForOrganizationID(ctx, scope, obj.ID, cursor) if err != nil { panic(fmt.Errorf("cannot list access review campaigns: %w", err)) } diff --git a/pkg/server/api/console/v1/types/access_review.go b/pkg/server/api/console/v1/types/access_review.go index 9b4b8ab0f..ef2ec5338 100644 --- a/pkg/server/api/console/v1/types/access_review.go +++ b/pkg/server/api/console/v1/types/access_review.go @@ -23,13 +23,13 @@ import ( ) type ( - AccessSourceOrderBy OrderBy[coredata.AccessSourceOrderField] + AccessReviewSourceOrderBy OrderBy[coredata.AccessReviewSourceOrderField] AccessReviewCampaignOrderBy OrderBy[coredata.AccessReviewCampaignOrderField] - AccessEntryOrderBy OrderBy[coredata.AccessEntryOrderField] + AccessReviewEntryOrderBy OrderBy[coredata.AccessReviewEntryOrderField] - AccessSourceConnection struct { + AccessReviewSourceConnection struct { TotalCount int - Edges []*AccessSourceEdge + Edges []*AccessReviewSourceEdge PageInfo PageInfo Resolver any @@ -45,32 +45,32 @@ type ( ParentID gid.GID } - AccessEntryConnection struct { + AccessReviewEntryConnection struct { TotalCount int - Edges []*AccessEntryEdge + Edges []*AccessReviewEntryEdge PageInfo PageInfo Resolver any ParentID gid.GID SourceID *gid.GID - Filter *coredata.AccessEntryFilter + Filter *coredata.AccessReviewEntryFilter } ) -// AccessSource helpers +// AccessReviewSource helpers -func NewAccessSourceConnection( - p *page.Page[*coredata.AccessSource, coredata.AccessSourceOrderField], +func NewAccessReviewSourceConnection( + p *page.Page[*coredata.AccessReviewSource, coredata.AccessReviewSourceOrderField], parentType any, parentID gid.GID, -) *AccessSourceConnection { - edges := make([]*AccessSourceEdge, len(p.Data)) +) *AccessReviewSourceConnection { + edges := make([]*AccessReviewSourceEdge, len(p.Data)) for i := range edges { - edges[i] = NewAccessSourceEdge(p.Data[i], p.Cursor.OrderBy.Field) + edges[i] = NewAccessReviewSourceEdge(p.Data[i], p.Cursor.OrderBy.Field) } - return &AccessSourceConnection{ + return &AccessReviewSourceConnection{ Edges: edges, PageInfo: *NewPageInfo(p), @@ -79,15 +79,15 @@ func NewAccessSourceConnection( } } -func NewAccessSourceEdge(s *coredata.AccessSource, orderBy coredata.AccessSourceOrderField) *AccessSourceEdge { - return &AccessSourceEdge{ +func NewAccessReviewSourceEdge(s *coredata.AccessReviewSource, orderBy coredata.AccessReviewSourceOrderField) *AccessReviewSourceEdge { + return &AccessReviewSourceEdge{ Cursor: s.CursorKey(orderBy), - Node: NewAccessSource(s), + Node: NewAccessReviewSource(s), } } -func NewAccessSource(s *coredata.AccessSource) *AccessSource { - return &AccessSource{ +func NewAccessReviewSource(s *coredata.AccessReviewSource) *AccessReviewSource { + return &AccessReviewSource{ ID: s.ID, Organization: &Organization{ ID: s.OrganizationID, @@ -100,11 +100,14 @@ func NewAccessSource(s *coredata.AccessSource) *AccessSource { } } -func NewAccessReviewCampaignScopeSource( - campaignID gid.GID, - source *coredata.AccessSource, - fetch *coredata.AccessReviewCampaignSourceFetch, -) *AccessReviewCampaignScopeSource { +// NewAccessReviewCampaignSource builds the GraphQL scope source from a +// campaign source snapshot. The current fetch state is derived from the latest +// fetch attempt (nil when the source has never been fetched). The live access +// source is resolved lazily via the source field resolver from SourceID. +func NewAccessReviewCampaignSource( + campaignSource *coredata.AccessReviewCampaignSource, + latestAttempt *coredata.AccessReviewCampaignSourceFetchAttempt, +) *AccessReviewCampaignSource { status := coredata.AccessReviewCampaignSourceFetchStatusQueued fetchedAccountsCount := 0 attemptCount := 0 @@ -115,20 +118,20 @@ func NewAccessReviewCampaignScopeSource( fetchCompletedAt *time.Time ) - if fetch != nil { - status = fetch.Status - fetchedAccountsCount = fetch.FetchedAccountsCount - attemptCount = fetch.AttemptCount - lastError = fetch.LastError - fetchStartedAt = fetch.StartedAt - fetchCompletedAt = fetch.CompletedAt + if latestAttempt != nil { + status = latestAttempt.Status + fetchedAccountsCount = latestAttempt.FetchedAccountsCount + attemptCount = latestAttempt.AttemptNumber + lastError = latestAttempt.Error + fetchStartedAt = latestAttempt.StartedAt + fetchCompletedAt = latestAttempt.CompletedAt } - return &AccessReviewCampaignScopeSource{ - ID: source.ID, - CampaignID: campaignID, - Source: NewAccessSource(source), - Name: source.Name, + return &AccessReviewCampaignSource{ + ID: campaignSource.ID, + CampaignID: campaignSource.AccessReviewCampaignID, + SourceID: campaignSource.AccessReviewSourceID, + Name: campaignSource.Name, FetchStatus: status, FetchedAccountsCount: fetchedAccountsCount, AttemptCount: attemptCount, @@ -138,6 +141,22 @@ func NewAccessReviewCampaignScopeSource( } } +// NewAccessReviewCampaignSourceFetchAttempt builds the GraphQL representation of a +// single append-only fetch attempt. +func NewAccessReviewCampaignSourceFetchAttempt(a *coredata.AccessReviewCampaignSourceFetchAttempt) *AccessReviewCampaignSourceFetchAttempt { + return &AccessReviewCampaignSourceFetchAttempt{ + ID: a.ID, + AttemptNumber: a.AttemptNumber, + Status: a.Status, + FetchedAccountsCount: a.FetchedAccountsCount, + Error: a.Error, + StartedAt: a.StartedAt, + CompletedAt: a.CompletedAt, + CreatedAt: a.CreatedAt, + UpdatedAt: a.UpdatedAt, + } +} + // AccessReviewCampaign helpers func NewAccessReviewCampaignConnection( @@ -186,8 +205,8 @@ func NewAccessReviewCampaign(c *coredata.AccessReviewCampaign) *AccessReviewCamp return campaign } -func NewAccessEntryDecisionHistoryEntry(h *coredata.AccessEntryDecisionHistory) *AccessEntryDecisionHistoryEntry { - entry := &AccessEntryDecisionHistoryEntry{ +func NewAccessReviewEntryDecisionHistoryEntry(h *coredata.AccessReviewEntryDecisionHistory) *AccessReviewEntryDecisionHistoryEntry { + entry := &AccessReviewEntryDecisionHistoryEntry{ ID: h.ID, Decision: h.Decision, DecisionNote: h.DecisionNote, @@ -202,22 +221,22 @@ func NewAccessEntryDecisionHistoryEntry(h *coredata.AccessEntryDecisionHistory) return entry } -// AccessEntry helpers +// AccessReviewEntry helpers -func NewAccessEntryConnection( - p *page.Page[*coredata.AccessEntry, coredata.AccessEntryOrderField], +func NewAccessReviewEntryConnection( + p *page.Page[*coredata.AccessReviewEntry, coredata.AccessReviewEntryOrderField], parentType any, parentID gid.GID, sourceID *gid.GID, - filter *coredata.AccessEntryFilter, -) *AccessEntryConnection { - edges := make([]*AccessEntryEdge, len(p.Data)) + filter *coredata.AccessReviewEntryFilter, +) *AccessReviewEntryConnection { + edges := make([]*AccessReviewEntryEdge, len(p.Data)) for i := range edges { - edges[i] = NewAccessEntryEdge(p.Data[i], p.Cursor.OrderBy.Field) + edges[i] = NewAccessReviewEntryEdge(p.Data[i], p.Cursor.OrderBy.Field) } - return &AccessEntryConnection{ + return &AccessReviewEntryConnection{ Edges: edges, PageInfo: *NewPageInfo(p), @@ -228,22 +247,19 @@ func NewAccessEntryConnection( } } -func NewAccessEntryEdge(e *coredata.AccessEntry, orderBy coredata.AccessEntryOrderField) *AccessEntryEdge { - return &AccessEntryEdge{ +func NewAccessReviewEntryEdge(e *coredata.AccessReviewEntry, orderBy coredata.AccessReviewEntryOrderField) *AccessReviewEntryEdge { + return &AccessReviewEntryEdge{ Cursor: e.CursorKey(orderBy), - Node: NewAccessEntry(e), + Node: NewAccessReviewEntry(e), } } -func NewAccessEntry(e *coredata.AccessEntry) *AccessEntry { - entry := &AccessEntry{ +func NewAccessReviewEntry(e *coredata.AccessReviewEntry) *AccessReviewEntry { + entry := &AccessReviewEntry{ ID: e.ID, Campaign: &AccessReviewCampaign{ ID: e.AccessReviewCampaignID, }, - AccessSource: &AccessSource{ - ID: e.AccessSourceID, - }, Email: e.Email, FullName: e.FullName, Role: e.Role, @@ -273,32 +289,32 @@ func NewAccessEntry(e *coredata.AccessEntry) *AccessEntry { return entry } -func NewAccessReviewCampaignStatistics(stats *coredata.AccessEntryStatistics) *AccessReviewCampaignStatistics { - decisionCounts := make([]*AccessEntryDecisionCount, 0, len(stats.DecisionCounts)) +func NewAccessReviewStatistics(stats *coredata.AccessReviewStatistics) *AccessReviewStatistics { + decisionCounts := make([]*AccessReviewEntryDecisionCount, 0, len(stats.DecisionCounts)) for decision, count := range stats.DecisionCounts { decisionCounts = append( decisionCounts, - &AccessEntryDecisionCount{Decision: decision, Count: count}, + &AccessReviewEntryDecisionCount{Decision: decision, Count: count}, ) } - flagCounts := make([]*AccessEntryFlagCount, 0, len(stats.FlagCounts)) + flagCounts := make([]*AccessReviewEntryFlagCount, 0, len(stats.FlagCounts)) for flag, count := range stats.FlagCounts { flagCounts = append( flagCounts, - &AccessEntryFlagCount{Flag: flag, Count: count}, + &AccessReviewEntryFlagCount{Flag: flag, Count: count}, ) } - incrementalTagCounts := make([]*AccessEntryIncrementalTagCount, 0, len(stats.IncrementalTagCounts)) + incrementalTagCounts := make([]*AccessReviewEntryIncrementalTagCount, 0, len(stats.IncrementalTagCounts)) for tag, count := range stats.IncrementalTagCounts { incrementalTagCounts = append( incrementalTagCounts, - &AccessEntryIncrementalTagCount{IncrementalTag: tag, Count: count}, + &AccessReviewEntryIncrementalTagCount{IncrementalTag: tag, Count: count}, ) } - return &AccessReviewCampaignStatistics{ + return &AccessReviewStatistics{ TotalCount: stats.TotalCount, DecisionCounts: decisionCounts, FlagCounts: flagCounts, diff --git a/pkg/server/api/console/v1/types/access_review_campaign_scope_source_test.go b/pkg/server/api/console/v1/types/access_review_campaign_scope_source_test.go deleted file mode 100644 index 0429616e3..000000000 --- a/pkg/server/api/console/v1/types/access_review_campaign_scope_source_test.go +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -// PERFORMANCE OF THIS SOFTWARE. - -package types - -import ( - "testing" - "time" - - "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/gid" -) - -func TestNewAccessReviewCampaignScopeSource_DefaultFetchState(t *testing.T) { - t.Parallel() - - tenantID := gid.NewTenantID() - source := &coredata.AccessSource{ - ID: gid.New(tenantID, coredata.AccessSourceEntityType), - OrganizationID: gid.New(tenantID, coredata.OrganizationEntityType), - Name: "Google Workspace", - } - - campaignID := gid.New(tenantID, coredata.AccessReviewCampaignEntityType) - - got := NewAccessReviewCampaignScopeSource(campaignID, source, nil) - if got.FetchStatus != coredata.AccessReviewCampaignSourceFetchStatusQueued { - t.Fatalf("fetch status = %q, want QUEUED", got.FetchStatus) - } - - if got.FetchedAccountsCount != 0 { - t.Fatalf("fetched accounts count = %d, want 0", got.FetchedAccountsCount) - } - - if got.AttemptCount != 0 { - t.Fatalf("attempt count = %d, want 0", got.AttemptCount) - } -} - -func TestNewAccessReviewCampaignScopeSource_UsesFetchState(t *testing.T) { - t.Parallel() - - now := time.Now() - errMsg := "connector timeout" - tenantID := gid.NewTenantID() - source := &coredata.AccessSource{ - ID: gid.New(tenantID, coredata.AccessSourceEntityType), - OrganizationID: gid.New(tenantID, coredata.OrganizationEntityType), - Name: "Linear", - } - fetch := &coredata.AccessReviewCampaignSourceFetch{ - Status: coredata.AccessReviewCampaignSourceFetchStatusFailed, - FetchedAccountsCount: 42, - AttemptCount: 3, - LastError: &errMsg, - StartedAt: &now, - CompletedAt: &now, - } - - campaignID := gid.New(tenantID, coredata.AccessReviewCampaignEntityType) - - got := NewAccessReviewCampaignScopeSource(campaignID, source, fetch) - if got.FetchStatus != coredata.AccessReviewCampaignSourceFetchStatusFailed { - t.Fatalf("fetch status = %q, want FAILED", got.FetchStatus) - } - - if got.FetchedAccountsCount != 42 { - t.Fatalf("fetched accounts count = %d, want 42", got.FetchedAccountsCount) - } - - if got.AttemptCount != 3 { - t.Fatalf("attempt count = %d, want 3", got.AttemptCount) - } - - if got.LastError == nil || *got.LastError != errMsg { - t.Fatalf("last error = %v, want %q", got.LastError, errMsg) - } -} diff --git a/pkg/server/api/console/v1/types/access_review_campaign_scope_source.go b/pkg/server/api/console/v1/types/access_review_campaign_source.go similarity index 59% rename from pkg/server/api/console/v1/types/access_review_campaign_scope_source.go rename to pkg/server/api/console/v1/types/access_review_campaign_source.go index abe6c3ac9..0a8614a62 100644 --- a/pkg/server/api/console/v1/types/access_review_campaign_scope_source.go +++ b/pkg/server/api/console/v1/types/access_review_campaign_source.go @@ -21,10 +21,12 @@ import ( "go.probo.inc/probo/pkg/gid" ) -type AccessReviewCampaignScopeSource struct { - ID gid.GID `json:"id"` - CampaignID gid.GID `json:"-"` - Source *AccessSource `json:"source"` +type AccessReviewCampaignSource struct { + ID gid.GID `json:"id"` + CampaignID gid.GID `json:"-"` + // SourceID is the live access source this snapshot points at, or nil once + // that source has been deleted. The source field is resolved lazily from it. + SourceID *gid.GID `json:"-"` Name string `json:"name"` FetchStatus coredata.AccessReviewCampaignSourceFetchStatus `json:"fetchStatus"` FetchedAccountsCount int `json:"fetchedAccountsCount"` @@ -33,3 +35,15 @@ type AccessReviewCampaignScopeSource struct { FetchStartedAt *time.Time `json:"fetchStartedAt,omitempty"` FetchCompletedAt *time.Time `json:"fetchCompletedAt,omitempty"` } + +type AccessReviewCampaignSourceFetchAttempt struct { + ID gid.GID `json:"id"` + AttemptNumber int `json:"attemptNumber"` + Status coredata.AccessReviewCampaignSourceFetchStatus `json:"status"` + FetchedAccountsCount int `json:"fetchedAccountsCount"` + Error *string `json:"error,omitempty"` + StartedAt *time.Time `json:"startedAt,omitempty"` + CompletedAt *time.Time `json:"completedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/pkg/server/api/console/v1/types/access_review_campaign_source_test.go b/pkg/server/api/console/v1/types/access_review_campaign_source_test.go new file mode 100644 index 000000000..0539f1770 --- /dev/null +++ b/pkg/server/api/console/v1/types/access_review_campaign_source_test.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package types + +import ( + "testing" + "time" + + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +func newTestCampaignSource(tenantID gid.TenantID, sourceID *gid.GID, name string) *coredata.AccessReviewCampaignSource { + return &coredata.AccessReviewCampaignSource{ + ID: gid.New(tenantID, coredata.AccessReviewCampaignSourceEntityType), + TenantID: tenantID, + AccessReviewCampaignID: gid.New(tenantID, coredata.AccessReviewCampaignEntityType), + AccessReviewSourceID: sourceID, + Name: name, + Category: coredata.AccessReviewSourceCategorySaaS, + } +} + +func TestNewAccessReviewCampaignSource_DefaultFetchState(t *testing.T) { + t.Parallel() + + tenantID := gid.NewTenantID() + sourceID := gid.New(tenantID, coredata.AccessReviewSourceEntityType) + campaignSource := newTestCampaignSource(tenantID, &sourceID, "Google Workspace") + + got := NewAccessReviewCampaignSource(campaignSource, nil) + if got.FetchStatus != coredata.AccessReviewCampaignSourceFetchStatusQueued { + t.Fatalf("fetch status = %q, want QUEUED", got.FetchStatus) + } + + if got.FetchedAccountsCount != 0 { + t.Fatalf("fetched accounts count = %d, want 0", got.FetchedAccountsCount) + } + + if got.AttemptCount != 0 { + t.Fatalf("attempt count = %d, want 0", got.AttemptCount) + } + + if got.SourceID == nil || *got.SourceID != sourceID { + t.Fatalf("source id = %v, want %v", got.SourceID, sourceID) + } + + if got.Name != "Google Workspace" { + t.Fatalf("name = %q, want snapshot name", got.Name) + } +} + +func TestNewAccessReviewCampaignSource_UsesLatestAttempt(t *testing.T) { + t.Parallel() + + now := time.Now() + errMsg := "We couldn't fetch accounts from this source." + tenantID := gid.NewTenantID() + sourceID := gid.New(tenantID, coredata.AccessReviewSourceEntityType) + campaignSource := newTestCampaignSource(tenantID, &sourceID, "Linear") + attempt := &coredata.AccessReviewCampaignSourceFetchAttempt{ + Status: coredata.AccessReviewCampaignSourceFetchStatusFailed, + FetchedAccountsCount: 42, + AttemptNumber: 3, + Error: &errMsg, + StartedAt: &now, + CompletedAt: &now, + } + + got := NewAccessReviewCampaignSource(campaignSource, attempt) + if got.FetchStatus != coredata.AccessReviewCampaignSourceFetchStatusFailed { + t.Fatalf("fetch status = %q, want FAILED", got.FetchStatus) + } + + if got.FetchedAccountsCount != 42 { + t.Fatalf("fetched accounts count = %d, want 42", got.FetchedAccountsCount) + } + + if got.AttemptCount != 3 { + t.Fatalf("attempt count = %d, want 3", got.AttemptCount) + } + + if got.LastError == nil || *got.LastError != errMsg { + t.Fatalf("last error = %v, want %q", got.LastError, errMsg) + } +} + +// TestNewAccessReviewCampaignSource_DeletedSource verifies a snapshot whose +// live source has been deleted still renders (with a nil source link) so the +// historical review data remains visible. +func TestNewAccessReviewCampaignSource_DeletedSource(t *testing.T) { + t.Parallel() + + tenantID := gid.NewTenantID() + campaignSource := newTestCampaignSource(tenantID, nil, "Deleted Source") + + got := NewAccessReviewCampaignSource(campaignSource, nil) + if got.SourceID != nil { + t.Fatalf("source id = %v, want nil for deleted source", got.SourceID) + } + + if got.Name != "Deleted Source" { + t.Fatalf("name = %q, want snapshot name preserved", got.Name) + } +} diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 02ccb76ce..a2732e2b8 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -3382,7 +3382,7 @@ func (r *Resolver) ListFindingAuditsTool(ctx context.Context, req *mcp.CallToolR // ListAccessReviewCampaignsTool handles the listAccessReviewCampaigns tool // List access review campaigns for an organization func (r *Resolver) ListAccessReviewCampaignsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListAccessReviewCampaignsInput) (*mcp.CallToolResult, types.ListAccessReviewCampaignsOutput, error) { - scope, err := r.Authorize(ctx, input.OrganizationID, probo.ActionAccessReviewCampaignList) + scope, err := r.Authorize(ctx, input.OrganizationID, accessreview.ActionCampaignList) if err != nil { return nil, types.ListAccessReviewCampaignsOutput{}, err } @@ -3401,7 +3401,7 @@ func (r *Resolver) ListAccessReviewCampaignsTool(ctx context.Context, req *mcp.C cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) - p, err := r.accessReview.Campaigns(scope).ListForOrganizationID(ctx, input.OrganizationID, cursor) + p, err := r.accessReview.ListCampaignsForOrganizationID(ctx, scope, input.OrganizationID, cursor) if err != nil { panic(fmt.Errorf("cannot list access review campaigns: %w", err)) } @@ -3412,18 +3412,18 @@ func (r *Resolver) ListAccessReviewCampaignsTool(ctx context.Context, req *mcp.C // ListAccessEntriesTool handles the listAccessEntries tool // List access entries for a campaign with optional filters func (r *Resolver) ListAccessEntriesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListAccessEntriesInput) (*mcp.CallToolResult, types.ListAccessEntriesOutput, error) { - scope, err := r.Authorize(ctx, input.CampaignID, probo.ActionAccessEntryList) + scope, err := r.Authorize(ctx, input.CampaignID, accessreview.ActionEntryList) if err != nil { return nil, types.ListAccessEntriesOutput{}, err } - pageOrderBy := page.OrderBy[coredata.AccessEntryOrderField]{ - Field: coredata.AccessEntryOrderFieldCreatedAt, + pageOrderBy := page.OrderBy[coredata.AccessReviewEntryOrderField]{ + Field: coredata.AccessReviewEntryOrderFieldCreatedAt, Direction: page.OrderDirectionDesc, } if input.OrderBy != nil { - pageOrderBy = page.OrderBy[coredata.AccessEntryOrderField]{ + pageOrderBy = page.OrderBy[coredata.AccessReviewEntryOrderField]{ Field: input.OrderBy.Field, Direction: input.OrderBy.Direction, } @@ -3431,9 +3431,9 @@ func (r *Resolver) ListAccessEntriesTool(ctx context.Context, req *mcp.CallToolR cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) - var filter *coredata.AccessEntryFilter + var filter *coredata.AccessReviewEntryFilter if input.Filter != nil { - filter = &coredata.AccessEntryFilter{ + filter = &coredata.AccessReviewEntryFilter{ Decision: input.Filter.Decision, Flag: input.Filter.Flag, IncrementalTag: input.Filter.IncrementalTag, @@ -3444,15 +3444,16 @@ func (r *Resolver) ListAccessEntriesTool(ctx context.Context, req *mcp.CallToolR } } - var p *page.Page[*coredata.AccessEntry, coredata.AccessEntryOrderField] + var p *page.Page[*coredata.AccessReviewEntry, coredata.AccessReviewEntryOrderField] - if input.AccessSourceID != nil { + if input.AccessReviewCampaignSourceID != nil { var err error - p, err = r.accessReview.Entries(scope).ListForCampaignIDAndSourceID( + p, err = r.accessReview.ListEntriesForCampaignIDAndSourceID( ctx, + scope, input.CampaignID, - *input.AccessSourceID, + *input.AccessReviewCampaignSourceID, cursor, filter, ) @@ -3462,7 +3463,7 @@ func (r *Resolver) ListAccessEntriesTool(ctx context.Context, req *mcp.CallToolR } else { var err error - p, err = r.accessReview.Entries(scope).ListForCampaignID(ctx, input.CampaignID, cursor, filter) + p, err = r.accessReview.ListEntriesForCampaignID(ctx, scope, input.CampaignID, cursor, filter) if err != nil { panic(fmt.Errorf("cannot list access entries: %w", err)) } @@ -3471,44 +3472,44 @@ func (r *Resolver) ListAccessEntriesTool(ctx context.Context, req *mcp.CallToolR return nil, types.NewListAccessEntriesOutput(p), nil } -// GetAccessReviewCampaignStatisticsTool handles the getAccessReviewCampaignStatistics tool +// GetAccessReviewStatisticsTool handles the getAccessReviewCampaignStatistics tool // Get statistics for an access review campaign -func (r *Resolver) GetAccessReviewCampaignStatisticsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetAccessReviewCampaignStatisticsInput) (*mcp.CallToolResult, types.GetAccessReviewCampaignStatisticsOutput, error) { - scope, err := r.Authorize(ctx, input.CampaignID, probo.ActionAccessReviewCampaignGet) +func (r *Resolver) GetAccessReviewStatisticsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetAccessReviewStatisticsInput) (*mcp.CallToolResult, types.GetAccessReviewStatisticsOutput, error) { + scope, err := r.Authorize(ctx, input.CampaignID, accessreview.ActionCampaignGet) if err != nil { - return nil, types.GetAccessReviewCampaignStatisticsOutput{}, err + return nil, types.GetAccessReviewStatisticsOutput{}, err } - stats, err := r.accessReview.Entries(scope).Statistics(ctx, input.CampaignID) + stats, err := r.accessReview.CampaignStatistics(ctx, scope, input.CampaignID) if err != nil { panic(fmt.Errorf("cannot get campaign statistics: %w", err)) } - return nil, types.GetAccessReviewCampaignStatisticsOutput{ - Statistics: types.NewAccessEntryStatistics(stats), + return nil, types.GetAccessReviewStatisticsOutput{ + Statistics: types.NewAccessReviewStatistics(stats), }, nil } -// RecordAccessEntryDecisionTool handles the recordAccessEntryDecision tool +// RecordAccessReviewEntryDecisionTool handles the recordAccessEntryDecision tool // Record a decision on an access entry -func (r *Resolver) RecordAccessEntryDecisionTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RecordAccessEntryDecisionInput) (*mcp.CallToolResult, types.RecordAccessEntryDecisionOutput, error) { - scope, err := r.Authorize(ctx, input.AccessEntryID, probo.ActionAccessEntryDecide) +func (r *Resolver) RecordAccessReviewEntryDecisionTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RecordAccessReviewEntryDecisionInput) (*mcp.CallToolResult, types.RecordAccessReviewEntryDecisionOutput, error) { + scope, err := r.Authorize(ctx, input.AccessReviewEntryID, accessreview.ActionEntryDecide) if err != nil { - return nil, types.RecordAccessEntryDecisionOutput{}, err + return nil, types.RecordAccessReviewEntryDecisionOutput{}, err } identity := authn.IdentityFromContext(ctx) if identity == nil { - return nil, types.RecordAccessEntryDecisionOutput{}, fmt.Errorf("no identity in context") + return nil, types.RecordAccessReviewEntryDecisionOutput{}, fmt.Errorf("no identity in context") } - decisionReq := accessreview.RecordAccessEntryDecisionRequest{ - EntryID: input.AccessEntryID, + decisionReq := accessreview.RecordAccessReviewEntryDecisionRequest{ + EntryID: input.AccessReviewEntryID, Decision: input.Decision, DecisionNote: input.DecisionNote, } - organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, input.AccessEntryID) + organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, input.AccessReviewEntryID) if err == nil { profile, err := r.iamSvc.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, organizationID) if err == nil { @@ -3516,53 +3517,53 @@ func (r *Resolver) RecordAccessEntryDecisionTool(ctx context.Context, req *mcp.C } } - entry, err := r.accessReview.Entries(scope).RecordDecision(ctx, decisionReq) + entry, err := r.accessReview.RecordDecision(ctx, scope, decisionReq) if err != nil { - return nil, types.RecordAccessEntryDecisionOutput{}, fmt.Errorf("cannot record decision: %w", err) + return nil, types.RecordAccessReviewEntryDecisionOutput{}, fmt.Errorf("cannot record decision: %w", err) } - return nil, types.RecordAccessEntryDecisionOutput{ - AccessEntry: types.NewAccessEntry(entry), + return nil, types.RecordAccessReviewEntryDecisionOutput{ + AccessEntry: types.NewAccessReviewEntry(entry), }, nil } -// RecordAccessEntryDecisionsTool handles the recordAccessEntryDecisions tool +// RecordAccessReviewEntryDecisionsTool handles the recordAccessEntryDecisions tool // Record decisions on multiple access entries in a single batch -func (r *Resolver) RecordAccessEntryDecisionsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RecordAccessEntryDecisionsInput) (*mcp.CallToolResult, types.RecordAccessEntryDecisionsOutput, error) { +func (r *Resolver) RecordAccessReviewEntryDecisionsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RecordAccessReviewEntryDecisionsInput) (*mcp.CallToolResult, types.RecordAccessReviewEntryDecisionsOutput, error) { if len(input.Decisions) == 0 { - return nil, types.RecordAccessEntryDecisionsOutput{ - AccessEntries: []*types.AccessEntry{}, + return nil, types.RecordAccessReviewEntryDecisionsOutput{ + AccessReviewEntries: []*types.AccessReviewEntry{}, }, nil } const maxBatchSize = 100 if len(input.Decisions) > maxBatchSize { - return nil, types.RecordAccessEntryDecisionsOutput{}, fmt.Errorf("cannot record decisions: batch size %d exceeds maximum of %d", len(input.Decisions), maxBatchSize) + return nil, types.RecordAccessReviewEntryDecisionsOutput{}, fmt.Errorf("cannot record decisions: batch size %d exceeds maximum of %d", len(input.Decisions), maxBatchSize) } // Authorize each entry individually to prevent cross-org bypass. for _, d := range input.Decisions { - if _, err := r.Authorize(ctx, d.AccessEntryID, probo.ActionAccessEntryDecide); err != nil { - return nil, types.RecordAccessEntryDecisionsOutput{}, err + if _, err := r.Authorize(ctx, d.AccessReviewEntryID, accessreview.ActionEntryDecide); err != nil { + return nil, types.RecordAccessReviewEntryDecisionsOutput{}, err } } - scope := coredata.NewScopeFromObjectID(input.Decisions[0].AccessEntryID) + scope := coredata.NewScopeFromObjectID(input.Decisions[0].AccessReviewEntryID) identity := authn.IdentityFromContext(ctx) if identity == nil { - return nil, types.RecordAccessEntryDecisionsOutput{}, fmt.Errorf("no identity in context") + return nil, types.RecordAccessReviewEntryDecisionsOutput{}, fmt.Errorf("no identity in context") } // Cache profile lookups per organization so we resolve the correct // decidedByID for each entry even when a batch spans multiple orgs. profileCache := make(map[gid.GID]*gid.GID) - decisions := make([]accessreview.RecordAccessEntryDecisionRequest, len(input.Decisions)) + decisions := make([]accessreview.RecordAccessReviewEntryDecisionRequest, len(input.Decisions)) for i, d := range input.Decisions { var decidedByID *gid.GID - organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, d.AccessEntryID) + organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, d.AccessReviewEntryID) if err == nil { if cached, ok := profileCache[organizationID]; ok { decidedByID = cached @@ -3576,38 +3577,38 @@ func (r *Resolver) RecordAccessEntryDecisionsTool(ctx context.Context, req *mcp. } } - decisions[i] = accessreview.RecordAccessEntryDecisionRequest{ - EntryID: d.AccessEntryID, + decisions[i] = accessreview.RecordAccessReviewEntryDecisionRequest{ + EntryID: d.AccessReviewEntryID, Decision: d.Decision, DecisionNote: d.DecisionNote, DecidedByID: decidedByID, } } - entries, err := r.accessReview.Entries(scope).RecordDecisions(ctx, decisions) + entries, err := r.accessReview.RecordDecisions(ctx, scope, decisions) if err != nil { - return nil, types.RecordAccessEntryDecisionsOutput{}, fmt.Errorf("cannot record decisions: %w", err) + return nil, types.RecordAccessReviewEntryDecisionsOutput{}, fmt.Errorf("cannot record decisions: %w", err) } - accessEntries := make([]*types.AccessEntry, len(entries)) + accessEntries := make([]*types.AccessReviewEntry, len(entries)) for i, e := range entries { - accessEntries[i] = types.NewAccessEntry(e) + accessEntries[i] = types.NewAccessReviewEntry(e) } - return nil, types.RecordAccessEntryDecisionsOutput{ - AccessEntries: accessEntries, + return nil, types.RecordAccessReviewEntryDecisionsOutput{ + AccessReviewEntries: accessEntries, }, nil } // CloseAccessReviewCampaignTool handles the closeAccessReviewCampaign tool // Close an access review campaign func (r *Resolver) CloseAccessReviewCampaignTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CloseAccessReviewCampaignInput) (*mcp.CallToolResult, types.CloseAccessReviewCampaignOutput, error) { - scope, err := r.Authorize(ctx, input.CampaignID, probo.ActionAccessReviewCampaignClose) + scope, err := r.Authorize(ctx, input.CampaignID, accessreview.ActionCampaignClose) if err != nil { return nil, types.CloseAccessReviewCampaignOutput{}, err } - campaign, err := r.accessReview.Campaigns(scope).Close(ctx, input.CampaignID) + campaign, err := r.accessReview.CloseCampaign(ctx, scope, input.CampaignID) if err != nil { return nil, types.CloseAccessReviewCampaignOutput{}, fmt.Errorf("cannot close campaign: %w", err) } @@ -3617,21 +3618,21 @@ func (r *Resolver) CloseAccessReviewCampaignTool(ctx context.Context, req *mcp.C }, nil } -// ListAccessSourcesTool handles the listAccessSources tool +// ListAccessReviewSourcesTool handles the listAccessSources tool // List access sources for an organization -func (r *Resolver) ListAccessSourcesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListAccessSourcesInput) (*mcp.CallToolResult, types.ListAccessSourcesOutput, error) { - scope, err := r.Authorize(ctx, input.OrganizationID, probo.ActionAccessSourceList) +func (r *Resolver) ListAccessReviewSourcesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListAccessReviewSourcesInput) (*mcp.CallToolResult, types.ListAccessReviewSourcesOutput, error) { + scope, err := r.Authorize(ctx, input.OrganizationID, accessreview.ActionSourceList) if err != nil { - return nil, types.ListAccessSourcesOutput{}, err + return nil, types.ListAccessReviewSourcesOutput{}, err } - pageOrderBy := page.OrderBy[coredata.AccessSourceOrderField]{ - Field: coredata.AccessSourceOrderFieldCreatedAt, + pageOrderBy := page.OrderBy[coredata.AccessReviewSourceOrderField]{ + Field: coredata.AccessReviewSourceOrderFieldCreatedAt, Direction: page.OrderDirectionDesc, } if input.OrderBy != nil { - pageOrderBy = page.OrderBy[coredata.AccessSourceOrderField]{ + pageOrderBy = page.OrderBy[coredata.AccessReviewSourceOrderField]{ Field: input.OrderBy.Field, Direction: input.OrderBy.Direction, } @@ -3639,56 +3640,56 @@ func (r *Resolver) ListAccessSourcesTool(ctx context.Context, req *mcp.CallToolR cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) - p, err := r.accessReview.Sources(scope).ListForOrganizationID(ctx, input.OrganizationID, cursor) + p, err := r.accessReview.ListSourcesForOrganizationID(ctx, scope, input.OrganizationID, cursor) if err != nil { panic(fmt.Errorf("cannot list access sources: %w", err)) } - return nil, types.NewListAccessSourcesOutput(p), nil + return nil, types.NewListAccessReviewSourcesOutput(p), nil } -// CreateAccessSourceTool handles the createAccessSource tool +// CreateAccessReviewSourceTool handles the createAccessSource tool // Create a new access source for an organization -func (r *Resolver) CreateAccessSourceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CreateAccessSourceInput) (*mcp.CallToolResult, types.CreateAccessSourceOutput, error) { - scope, err := r.Authorize(ctx, input.OrganizationID, probo.ActionAccessSourceCreate) +func (r *Resolver) CreateAccessReviewSourceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CreateAccessReviewSourceInput) (*mcp.CallToolResult, types.CreateAccessReviewSourceOutput, error) { + scope, err := r.Authorize(ctx, input.OrganizationID, accessreview.ActionSourceCreate) if err != nil { - return nil, types.CreateAccessSourceOutput{}, err + return nil, types.CreateAccessReviewSourceOutput{}, err } - source, err := r.accessReview.Sources(scope).Create(ctx, accessreview.CreateAccessSourceRequest{ + source, err := r.accessReview.CreateSource(ctx, scope, accessreview.CreateAccessReviewSourceRequest{ OrganizationID: input.OrganizationID, ConnectorID: input.ConnectorID, Name: input.Name, - Category: coredata.AccessSourceCategorySaaS, + Category: coredata.AccessReviewSourceCategorySaaS, CsvData: input.CsvData, }) if err != nil { - return nil, types.CreateAccessSourceOutput{}, fmt.Errorf("cannot create access source: %w", err) + return nil, types.CreateAccessReviewSourceOutput{}, fmt.Errorf("cannot create access source: %w", err) } - return nil, types.CreateAccessSourceOutput{ - AccessSource: types.NewAccessSource(source), + return nil, types.CreateAccessReviewSourceOutput{ + AccessReviewSource: types.NewAccessReviewSource(source), }, nil } -// UpdateAccessSourceTool handles the updateAccessSource tool +// UpdateAccessReviewSourceTool handles the updateAccessSource tool // Update an existing access source -func (r *Resolver) UpdateAccessSourceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateAccessSourceInput) (*mcp.CallToolResult, types.UpdateAccessSourceOutput, error) { - scope, err := r.Authorize(ctx, input.AccessSourceID, probo.ActionAccessSourceUpdate) +func (r *Resolver) UpdateAccessReviewSourceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateAccessReviewSourceInput) (*mcp.CallToolResult, types.UpdateAccessReviewSourceOutput, error) { + scope, err := r.Authorize(ctx, input.AccessReviewSourceID, accessreview.ActionSourceUpdate) if err != nil { - return nil, types.UpdateAccessSourceOutput{}, err + return nil, types.UpdateAccessReviewSourceOutput{}, err } - updateReq := accessreview.UpdateAccessSourceRequest{ - AccessSourceID: input.AccessSourceID, - Name: input.Name, + updateReq := accessreview.UpdateAccessReviewSourceRequest{ + AccessReviewSourceID: input.AccessReviewSourceID, + Name: input.Name, } if rawConnectorID := UnwrapOmittable(input.ConnectorID); rawConnectorID != nil { if *rawConnectorID != nil { id, err := gid.ParseGID(**rawConnectorID) if err != nil { - return nil, types.UpdateAccessSourceOutput{}, fmt.Errorf("cannot parse connector_id: %w", err) + return nil, types.UpdateAccessReviewSourceOutput{}, fmt.Errorf("cannot parse connector_id: %w", err) } idPtr := &id @@ -3704,37 +3705,37 @@ func (r *Resolver) UpdateAccessSourceTool(ctx context.Context, req *mcp.CallTool updateReq.CsvData = rawCsvData } - source, err := r.accessReview.Sources(scope).Update(ctx, updateReq) + source, err := r.accessReview.UpdateSource(ctx, scope, updateReq) if err != nil { - return nil, types.UpdateAccessSourceOutput{}, fmt.Errorf("cannot update access source: %w", err) + return nil, types.UpdateAccessReviewSourceOutput{}, fmt.Errorf("cannot update access source: %w", err) } - return nil, types.UpdateAccessSourceOutput{ - AccessSource: types.NewAccessSource(source), + return nil, types.UpdateAccessReviewSourceOutput{ + AccessReviewSource: types.NewAccessReviewSource(source), }, nil } -// DeleteAccessSourceTool handles the deleteAccessSource tool +// DeleteAccessReviewSourceTool handles the deleteAccessSource tool // Delete an access source -func (r *Resolver) DeleteAccessSourceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteAccessSourceInput) (*mcp.CallToolResult, types.DeleteAccessSourceOutput, error) { - scope, err := r.Authorize(ctx, input.AccessSourceID, probo.ActionAccessSourceDelete) +func (r *Resolver) DeleteAccessReviewSourceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteAccessReviewSourceInput) (*mcp.CallToolResult, types.DeleteAccessReviewSourceOutput, error) { + scope, err := r.Authorize(ctx, input.AccessReviewSourceID, accessreview.ActionSourceDelete) if err != nil { - return nil, types.DeleteAccessSourceOutput{}, err + return nil, types.DeleteAccessReviewSourceOutput{}, err } - if err := r.accessReview.Sources(scope).Delete(ctx, input.AccessSourceID); err != nil { - return nil, types.DeleteAccessSourceOutput{}, fmt.Errorf("cannot delete access source: %w", err) + if err := r.accessReview.DeleteSource(ctx, scope, input.AccessReviewSourceID); err != nil { + return nil, types.DeleteAccessReviewSourceOutput{}, fmt.Errorf("cannot delete access source: %w", err) } - return nil, types.DeleteAccessSourceOutput{ - DeletedAccessSourceID: input.AccessSourceID, + return nil, types.DeleteAccessReviewSourceOutput{ + DeletedAccessReviewSourceID: input.AccessReviewSourceID, }, nil } // CreateAccessReviewCampaignTool handles the createAccessReviewCampaign tool // Create a new access review campaign for an organization func (r *Resolver) CreateAccessReviewCampaignTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CreateAccessReviewCampaignInput) (*mcp.CallToolResult, types.CreateAccessReviewCampaignOutput, error) { - scope, err := r.Authorize(ctx, input.OrganizationID, probo.ActionAccessReviewCampaignCreate) + scope, err := r.Authorize(ctx, input.OrganizationID, accessreview.ActionCampaignCreate) if err != nil { return nil, types.CreateAccessReviewCampaignOutput{}, err } @@ -3744,12 +3745,12 @@ func (r *Resolver) CreateAccessReviewCampaignTool(ctx context.Context, req *mcp. description = *input.Description } - campaign, err := r.accessReview.Campaigns(scope).Create(ctx, accessreview.CreateAccessReviewCampaignRequest{ - OrganizationID: input.OrganizationID, - Name: input.Name, - Description: description, - FrameworkControls: input.FrameworkControls, - AccessSourceIDs: input.AccessSourceIds, + campaign, err := r.accessReview.CreateCampaign(ctx, scope, accessreview.CreateAccessReviewCampaignRequest{ + OrganizationID: input.OrganizationID, + Name: input.Name, + Description: description, + FrameworkControls: input.FrameworkControls, + AccessReviewSourceIDs: input.AccessReviewSourceIds, }) if err != nil { return nil, types.CreateAccessReviewCampaignOutput{}, fmt.Errorf("cannot create access review campaign: %w", err) @@ -3763,7 +3764,7 @@ func (r *Resolver) CreateAccessReviewCampaignTool(ctx context.Context, req *mcp. // UpdateAccessReviewCampaignTool handles the updateAccessReviewCampaign tool // Update an existing access review campaign func (r *Resolver) UpdateAccessReviewCampaignTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateAccessReviewCampaignInput) (*mcp.CallToolResult, types.UpdateAccessReviewCampaignOutput, error) { - scope, err := r.Authorize(ctx, input.CampaignID, probo.ActionAccessReviewCampaignUpdate) + scope, err := r.Authorize(ctx, input.CampaignID, accessreview.ActionCampaignUpdate) if err != nil { return nil, types.UpdateAccessReviewCampaignOutput{}, err } @@ -3790,7 +3791,7 @@ func (r *Resolver) UpdateAccessReviewCampaignTool(ctx context.Context, req *mcp. } } - campaign, err := r.accessReview.Campaigns(scope).Update(ctx, updateReq) + campaign, err := r.accessReview.UpdateCampaign(ctx, scope, updateReq) if err != nil { return nil, types.UpdateAccessReviewCampaignOutput{}, fmt.Errorf("cannot update access review campaign: %w", err) } @@ -3803,12 +3804,12 @@ func (r *Resolver) UpdateAccessReviewCampaignTool(ctx context.Context, req *mcp. // DeleteAccessReviewCampaignTool handles the deleteAccessReviewCampaign tool // Delete an access review campaign func (r *Resolver) DeleteAccessReviewCampaignTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteAccessReviewCampaignInput) (*mcp.CallToolResult, types.DeleteAccessReviewCampaignOutput, error) { - scope, err := r.Authorize(ctx, input.CampaignID, probo.ActionAccessReviewCampaignDelete) + scope, err := r.Authorize(ctx, input.CampaignID, accessreview.ActionCampaignDelete) if err != nil { return nil, types.DeleteAccessReviewCampaignOutput{}, err } - if err := r.accessReview.Campaigns(scope).Delete(ctx, input.CampaignID); err != nil { + if err := r.accessReview.DeleteCampaign(ctx, scope, input.CampaignID); err != nil { return nil, types.DeleteAccessReviewCampaignOutput{}, fmt.Errorf("cannot delete access review campaign: %w", err) } @@ -3820,12 +3821,12 @@ func (r *Resolver) DeleteAccessReviewCampaignTool(ctx context.Context, req *mcp. // StartAccessReviewCampaignTool handles the startAccessReviewCampaign tool // Start an access review campaign func (r *Resolver) StartAccessReviewCampaignTool(ctx context.Context, req *mcp.CallToolRequest, input *types.StartAccessReviewCampaignInput) (*mcp.CallToolResult, types.StartAccessReviewCampaignOutput, error) { - scope, err := r.Authorize(ctx, input.CampaignID, probo.ActionAccessReviewCampaignStart) + scope, err := r.Authorize(ctx, input.CampaignID, accessreview.ActionCampaignStart) if err != nil { return nil, types.StartAccessReviewCampaignOutput{}, err } - campaign, err := r.accessReview.Campaigns(scope).Start(ctx, input.CampaignID) + campaign, err := r.accessReview.StartCampaign(ctx, scope, input.CampaignID) if err != nil { return nil, types.StartAccessReviewCampaignOutput{}, fmt.Errorf("cannot start access review campaign: %w", err) } @@ -3838,12 +3839,12 @@ func (r *Resolver) StartAccessReviewCampaignTool(ctx context.Context, req *mcp.C // CancelAccessReviewCampaignTool handles the cancelAccessReviewCampaign tool // Cancel an in-progress access review campaign func (r *Resolver) CancelAccessReviewCampaignTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CancelAccessReviewCampaignInput) (*mcp.CallToolResult, types.CancelAccessReviewCampaignOutput, error) { - scope, err := r.Authorize(ctx, input.CampaignID, probo.ActionAccessReviewCampaignCancel) + scope, err := r.Authorize(ctx, input.CampaignID, accessreview.ActionCampaignCancel) if err != nil { return nil, types.CancelAccessReviewCampaignOutput{}, err } - campaign, err := r.accessReview.Campaigns(scope).Cancel(ctx, input.CampaignID) + campaign, err := r.accessReview.CancelCampaign(ctx, scope, input.CampaignID) if err != nil { return nil, types.CancelAccessReviewCampaignOutput{}, fmt.Errorf("cannot cancel access review campaign: %w", err) } @@ -3853,67 +3854,67 @@ func (r *Resolver) CancelAccessReviewCampaignTool(ctx context.Context, req *mcp. }, nil } -// AddAccessReviewCampaignScopeSourceTool handles the addAccessReviewCampaignScopeSource tool +// AddAccessReviewCampaignSourceTool handles the addAccessReviewCampaignScopeSource tool // Add an access source to an access review campaign's scope -func (r *Resolver) AddAccessReviewCampaignScopeSourceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddAccessReviewCampaignScopeSourceInput) (*mcp.CallToolResult, types.AddAccessReviewCampaignScopeSourceOutput, error) { - scope, err := r.Authorize(ctx, input.CampaignID, probo.ActionAccessReviewCampaignAddScopeSource) +func (r *Resolver) AddAccessReviewCampaignSourceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddAccessReviewCampaignSourceInput) (*mcp.CallToolResult, types.AddAccessReviewCampaignSourceOutput, error) { + scope, err := r.Authorize(ctx, input.CampaignID, accessreview.ActionCampaignAddSource) if err != nil { - return nil, types.AddAccessReviewCampaignScopeSourceOutput{}, err + return nil, types.AddAccessReviewCampaignSourceOutput{}, err } - campaign, err := r.accessReview.Campaigns(scope).AddScopeSource(ctx, accessreview.AddCampaignScopeSourceRequest{ - CampaignID: input.CampaignID, - AccessSourceID: input.AccessSourceID, + campaign, err := r.accessReview.AddCampaignSource(ctx, scope, accessreview.AddCampaignSourceRequest{ + CampaignID: input.CampaignID, + AccessReviewSourceID: input.AccessReviewSourceID, }) if err != nil { - return nil, types.AddAccessReviewCampaignScopeSourceOutput{}, fmt.Errorf("cannot add scope source to access review campaign: %w", err) + return nil, types.AddAccessReviewCampaignSourceOutput{}, fmt.Errorf("cannot add scope source to access review campaign: %w", err) } - return nil, types.AddAccessReviewCampaignScopeSourceOutput{ + return nil, types.AddAccessReviewCampaignSourceOutput{ Campaign: types.NewAccessReviewCampaign(campaign), }, nil } -// RemoveAccessReviewCampaignScopeSourceTool handles the removeAccessReviewCampaignScopeSource tool +// RemoveAccessReviewCampaignSourceTool handles the removeAccessReviewCampaignScopeSource tool // Remove an access source from an access review campaign's scope -func (r *Resolver) RemoveAccessReviewCampaignScopeSourceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RemoveAccessReviewCampaignScopeSourceInput) (*mcp.CallToolResult, types.RemoveAccessReviewCampaignScopeSourceOutput, error) { - scope, err := r.Authorize(ctx, input.CampaignID, probo.ActionAccessReviewCampaignRemoveScopeSource) +func (r *Resolver) RemoveAccessReviewCampaignSourceTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RemoveAccessReviewCampaignSourceInput) (*mcp.CallToolResult, types.RemoveAccessReviewCampaignSourceOutput, error) { + scope, err := r.Authorize(ctx, input.CampaignID, accessreview.ActionCampaignRemoveSource) if err != nil { - return nil, types.RemoveAccessReviewCampaignScopeSourceOutput{}, err + return nil, types.RemoveAccessReviewCampaignSourceOutput{}, err } - campaign, err := r.accessReview.Campaigns(scope).RemoveScopeSource(ctx, accessreview.RemoveCampaignScopeSourceRequest{ - CampaignID: input.CampaignID, - AccessSourceID: input.AccessSourceID, + campaign, err := r.accessReview.RemoveCampaignSource(ctx, scope, accessreview.RemoveCampaignSourceRequest{ + CampaignID: input.CampaignID, + AccessReviewSourceID: input.AccessReviewSourceID, }) if err != nil { - return nil, types.RemoveAccessReviewCampaignScopeSourceOutput{}, fmt.Errorf("cannot remove scope source from access review campaign: %w", err) + return nil, types.RemoveAccessReviewCampaignSourceOutput{}, fmt.Errorf("cannot remove scope source from access review campaign: %w", err) } - return nil, types.RemoveAccessReviewCampaignScopeSourceOutput{ + return nil, types.RemoveAccessReviewCampaignSourceOutput{ Campaign: types.NewAccessReviewCampaign(campaign), }, nil } -// FlagAccessEntryTool handles the flagAccessEntry tool +// FlagAccessReviewEntryTool handles the flagAccessEntry tool // Flag an access entry during review -func (r *Resolver) FlagAccessEntryTool(ctx context.Context, req *mcp.CallToolRequest, input *types.FlagAccessEntryInput) (*mcp.CallToolResult, types.FlagAccessEntryOutput, error) { - scope, err := r.Authorize(ctx, input.AccessEntryID, probo.ActionAccessEntryFlag) +func (r *Resolver) FlagAccessReviewEntryTool(ctx context.Context, req *mcp.CallToolRequest, input *types.FlagAccessReviewEntryInput) (*mcp.CallToolResult, types.FlagAccessReviewEntryOutput, error) { + scope, err := r.Authorize(ctx, input.AccessReviewEntryID, accessreview.ActionEntryFlag) if err != nil { - return nil, types.FlagAccessEntryOutput{}, err + return nil, types.FlagAccessReviewEntryOutput{}, err } - entry, err := r.accessReview.Entries(scope).FlagEntry(ctx, accessreview.FlagAccessEntryRequest{ - EntryID: input.AccessEntryID, + entry, err := r.accessReview.FlagEntry(ctx, scope, accessreview.FlagAccessReviewEntryRequest{ + EntryID: input.AccessReviewEntryID, Flags: input.Flags, FlagReasons: input.FlagReasons, }) if err != nil { - return nil, types.FlagAccessEntryOutput{}, fmt.Errorf("cannot flag access entry: %w", err) + return nil, types.FlagAccessReviewEntryOutput{}, fmt.Errorf("cannot flag access entry: %w", err) } - return nil, types.FlagAccessEntryOutput{ - AccessEntry: types.NewAccessEntry(entry), + return nil, types.FlagAccessReviewEntryOutput{ + AccessEntry: types.NewAccessReviewEntry(entry), }, nil } diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 699d1f803..f6d37ab00 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -7524,7 +7524,7 @@ components: - CANCELLED go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessReviewCampaignStatus - AccessEntryDecision: + AccessReviewEntryDecision: type: string enum: - PENDING @@ -7532,9 +7532,9 @@ components: - REVOKE - DEFER - ESCALATE - go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessEntryDecision + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessReviewEntryDecision - AccessEntryFlag: + AccessReviewEntryFlag: type: string enum: - NONE @@ -7552,17 +7552,17 @@ components: - NO_BUSINESS_JUSTIFICATION - OUT_OF_DEPARTMENT - SHARED_ACCOUNT - go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessEntryFlag + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlag - AccessEntryIncrementalTag: + AccessReviewEntryIncrementalTag: type: string enum: - NEW - REMOVED - UNCHANGED - go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessEntryIncrementalTag + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessReviewEntryIncrementalTag - AccessEntryAuthMethod: + AccessReviewEntryAuthMethod: type: string enum: - SSO @@ -7570,14 +7570,14 @@ components: - API_KEY - SERVICE_ACCOUNT - UNKNOWN - go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethod + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessReviewEntryAuthMethod - AccessEntryAccountType: + AccessReviewEntryAccountType: type: string enum: - USER - SERVICE_ACCOUNT - go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessEntryAccountType + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessReviewEntryAccountType MFAStatus: type: string @@ -7606,20 +7606,20 @@ components: $ref: "#/components/schemas/OrderDirection" description: Order direction - AccessEntryOrderField: + AccessReviewEntryOrderField: type: string enum: - CREATED_AT - go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessEntryOrderField + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessReviewEntryOrderField - AccessEntryOrderBy: + AccessReviewEntryOrderBy: type: object required: - field - direction properties: field: - $ref: "#/components/schemas/AccessEntryOrderField" + $ref: "#/components/schemas/AccessReviewEntryOrderField" description: Order field direction: $ref: "#/components/schemas/OrderDirection" @@ -7676,12 +7676,12 @@ components: format: date-time description: Update timestamp - AccessEntry: + AccessReviewEntry: type: object required: - id - campaign_id - - access_source_id + - access_review_campaign_source_id - email - full_name - role @@ -7703,9 +7703,9 @@ components: campaign_id: $ref: "#/components/schemas/GID" description: Campaign ID - access_source_id: + access_review_campaign_source_id: $ref: "#/components/schemas/GID" - description: Access source ID + description: Per-campaign source snapshot ID email: type: string description: User email @@ -7730,10 +7730,10 @@ components: $ref: "#/components/schemas/MFAStatus" description: MFA status auth_method: - $ref: "#/components/schemas/AccessEntryAuthMethod" + $ref: "#/components/schemas/AccessReviewEntryAuthMethod" description: Authentication method account_type: - $ref: "#/components/schemas/AccessEntryAccountType" + $ref: "#/components/schemas/AccessReviewEntryAccountType" description: Account type (user or service account) last_login: type: @@ -7751,12 +7751,12 @@ components: type: string description: External ID in the source system incremental_tag: - $ref: "#/components/schemas/AccessEntryIncrementalTag" + $ref: "#/components/schemas/AccessReviewEntryIncrementalTag" description: Change tag compared to previous campaign flags: type: array items: - $ref: "#/components/schemas/AccessEntryFlag" + $ref: "#/components/schemas/AccessReviewEntryFlag" description: Risk flags flag_reasons: type: array @@ -7764,7 +7764,7 @@ components: type: string description: Reasons for the flags decision: - $ref: "#/components/schemas/AccessEntryDecision" + $ref: "#/components/schemas/AccessReviewEntryDecision" description: Review decision decision_note: type: @@ -7791,7 +7791,7 @@ components: format: date-time description: Update timestamp - AccessEntryStatistics: + AccessReviewStatistics: type: object required: - total_count @@ -7857,11 +7857,11 @@ components: campaign_id: $ref: "#/components/schemas/GID" description: Campaign ID - access_source_id: + access_review_campaign_source_id: $ref: "#/components/schemas/GID" - description: Filter by access source ID + description: Filter by per-campaign source snapshot ID order_by: - $ref: "#/components/schemas/AccessEntryOrderBy" + $ref: "#/components/schemas/AccessReviewEntryOrderBy" description: Order by size: type: integer @@ -7873,13 +7873,13 @@ components: type: object properties: decision: - $ref: "#/components/schemas/AccessEntryDecision" + $ref: "#/components/schemas/AccessReviewEntryDecision" description: Filter by decision status flag: - $ref: "#/components/schemas/AccessEntryFlag" + $ref: "#/components/schemas/AccessReviewEntryFlag" description: Filter by flag incremental_tag: - $ref: "#/components/schemas/AccessEntryIncrementalTag" + $ref: "#/components/schemas/AccessReviewEntryIncrementalTag" description: Filter by incremental tag is_admin: type: boolean @@ -7888,10 +7888,10 @@ components: type: boolean description: Filter by active status at the source auth_method: - $ref: "#/components/schemas/AccessEntryAuthMethod" + $ref: "#/components/schemas/AccessReviewEntryAuthMethod" description: Filter by auth method account_type: - $ref: "#/components/schemas/AccessEntryAccountType" + $ref: "#/components/schemas/AccessReviewEntryAccountType" description: Filter by account type ListAccessEntriesOutput: @@ -7905,9 +7905,9 @@ components: entries: type: array items: - $ref: "#/components/schemas/AccessEntry" + $ref: "#/components/schemas/AccessReviewEntry" - GetAccessReviewCampaignStatisticsInput: + GetAccessReviewStatisticsInput: type: object required: - campaign_id @@ -7916,39 +7916,39 @@ components: $ref: "#/components/schemas/GID" description: Campaign ID - GetAccessReviewCampaignStatisticsOutput: + GetAccessReviewStatisticsOutput: type: object required: - statistics properties: statistics: - $ref: "#/components/schemas/AccessEntryStatistics" + $ref: "#/components/schemas/AccessReviewStatistics" - RecordAccessEntryDecisionMCPInput: + RecordAccessReviewEntryDecisionMCPInput: type: object required: - - access_entry_id + - access_review_entry_id - decision properties: - access_entry_id: + access_review_entry_id: $ref: "#/components/schemas/GID" description: Access entry ID decision: - $ref: "#/components/schemas/AccessEntryDecision" + $ref: "#/components/schemas/AccessReviewEntryDecision" description: Decision (APPROVED, REVOKE, DEFER, ESCALATE) decision_note: type: string description: Decision justification (required for non-APPROVED decisions) - RecordAccessEntryDecisionMCPOutput: + RecordAccessReviewEntryDecisionMCPOutput: type: object required: - access_entry properties: access_entry: - $ref: "#/components/schemas/AccessEntry" + $ref: "#/components/schemas/AccessReviewEntry" - RecordAccessEntryDecisionsMCPInput: + RecordAccessReviewEntryDecisionsMCPInput: type: object required: - decisions @@ -7958,42 +7958,42 @@ components: items: type: object required: - - access_entry_id + - access_review_entry_id - decision properties: - access_entry_id: + access_review_entry_id: $ref: "#/components/schemas/GID" description: Access entry ID decision: - $ref: "#/components/schemas/AccessEntryDecision" + $ref: "#/components/schemas/AccessReviewEntryDecision" description: Decision (APPROVED, REVOKE, DEFER, ESCALATE) decision_note: type: string description: Decision justification (required for non-APPROVED decisions) - RecordAccessEntryDecisionsMCPOutput: + RecordAccessReviewEntryDecisionsMCPOutput: type: object required: - - access_entries + - access_review_entries properties: - access_entries: + access_review_entries: type: array items: - $ref: "#/components/schemas/AccessEntry" + $ref: "#/components/schemas/AccessReviewEntry" - FlagAccessEntryMCPInput: + FlagAccessReviewEntryMCPInput: type: object required: - - access_entry_id + - access_review_entry_id - flags properties: - access_entry_id: + access_review_entry_id: $ref: "#/components/schemas/GID" description: Access entry ID flags: type: array items: - $ref: "#/components/schemas/AccessEntryFlag" + $ref: "#/components/schemas/AccessReviewEntryFlag" description: Flags to set (ORPHANED, INACTIVE, EXCESSIVE, ROLE_MISMATCH, NEW, DORMANT, TERMINATED_USER, CONTRACTOR_EXPIRED, SOD_CONFLICT, PRIVILEGED_ACCESS, ROLE_CREEP, NO_BUSINESS_JUSTIFICATION, OUT_OF_DEPARTMENT, SHARED_ACCOUNT) flag_reasons: type: array @@ -8001,13 +8001,13 @@ components: type: string description: Reasons for flagging - FlagAccessEntryMCPOutput: + FlagAccessReviewEntryMCPOutput: type: object required: - access_entry properties: access_entry: - $ref: "#/components/schemas/AccessEntry" + $ref: "#/components/schemas/AccessReviewEntry" CloseAccessReviewCampaignMCPInput: type: object @@ -8026,35 +8026,35 @@ components: campaign: $ref: "#/components/schemas/AccessReviewCampaign" - AccessSourceCategory: + AccessReviewSourceCategory: type: string - go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessSourceCategory + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategory enum: - SAAS - CLOUD_INFRA - SOURCE_CODE - OTHER - AccessSourceOrderField: + AccessReviewSourceOrderField: type: string - go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessSourceOrderField + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessReviewSourceOrderField enum: - CREATED_AT - AccessSourceOrderBy: + AccessReviewSourceOrderBy: type: object required: - field - direction properties: field: - $ref: "#/components/schemas/AccessSourceOrderField" + $ref: "#/components/schemas/AccessReviewSourceOrderField" description: Order field direction: $ref: "#/components/schemas/OrderDirection" description: Order direction - AccessSource: + AccessReviewSource: type: object required: - id @@ -8091,7 +8091,7 @@ components: format: date-time description: Update timestamp - ListAccessSourcesInput: + ListAccessReviewSourcesInput: type: object required: - organization_id @@ -8100,7 +8100,7 @@ components: $ref: "#/components/schemas/GID" description: Organization ID order_by: - $ref: "#/components/schemas/AccessSourceOrderBy" + $ref: "#/components/schemas/AccessReviewSourceOrderBy" description: Order by size: type: integer @@ -8109,20 +8109,20 @@ components: $ref: "#/components/schemas/CursorKey" description: Page cursor - ListAccessSourcesOutput: + ListAccessReviewSourcesOutput: type: object required: - - access_sources + - access_review_sources properties: next_cursor: $ref: "#/components/schemas/CursorKey" description: Next cursor - access_sources: + access_review_sources: type: array items: - $ref: "#/components/schemas/AccessSource" + $ref: "#/components/schemas/AccessReviewSource" - CreateAccessSourceMCPInput: + CreateAccessReviewSourceMCPInput: type: object required: - organization_id @@ -8141,20 +8141,20 @@ components: type: string description: CSV data for manual sources (optional) - CreateAccessSourceMCPOutput: + CreateAccessReviewSourceMCPOutput: type: object required: - access_source properties: - access_source: - $ref: "#/components/schemas/AccessSource" + access_review_source: + $ref: "#/components/schemas/AccessReviewSource" - UpdateAccessSourceMCPInput: + UpdateAccessReviewSourceMCPInput: type: object required: - - access_source_id + - access_review_source_id properties: - access_source_id: + access_review_source_id: $ref: "#/components/schemas/GID" description: Access source ID name: @@ -8173,29 +8173,29 @@ components: go.probo.inc/mcpgen/omittable: true description: CSV data for manual sources (set to null to remove) - UpdateAccessSourceMCPOutput: + UpdateAccessReviewSourceMCPOutput: type: object required: - access_source properties: - access_source: - $ref: "#/components/schemas/AccessSource" + access_review_source: + $ref: "#/components/schemas/AccessReviewSource" - DeleteAccessSourceMCPInput: + DeleteAccessReviewSourceMCPInput: type: object required: - - access_source_id + - access_review_source_id properties: - access_source_id: + access_review_source_id: $ref: "#/components/schemas/GID" description: Access source ID - DeleteAccessSourceMCPOutput: + DeleteAccessReviewSourceMCPOutput: type: object required: - - deleted_access_source_id + - deleted_access_review_source_id properties: - deleted_access_source_id: + deleted_access_review_source_id: $ref: "#/components/schemas/GID" description: Deleted access source ID @@ -8219,7 +8219,7 @@ components: items: type: string description: Framework control references - access_source_ids: + access_review_source_ids: type: array items: $ref: "#/components/schemas/GID" @@ -8316,20 +8316,20 @@ components: campaign: $ref: "#/components/schemas/AccessReviewCampaign" - AddAccessReviewCampaignScopeSourceMCPInput: + AddAccessReviewCampaignSourceMCPInput: type: object required: - campaign_id - - access_source_id + - access_review_source_id properties: campaign_id: $ref: "#/components/schemas/GID" description: Campaign ID - access_source_id: + access_review_source_id: $ref: "#/components/schemas/GID" description: Access source ID to add to scope - AddAccessReviewCampaignScopeSourceMCPOutput: + AddAccessReviewCampaignSourceMCPOutput: type: object required: - campaign @@ -8337,20 +8337,20 @@ components: campaign: $ref: "#/components/schemas/AccessReviewCampaign" - RemoveAccessReviewCampaignScopeSourceMCPInput: + RemoveAccessReviewCampaignSourceMCPInput: type: object required: - campaign_id - - access_source_id + - access_review_source_id properties: campaign_id: $ref: "#/components/schemas/GID" description: Campaign ID - access_source_id: + access_review_source_id: $ref: "#/components/schemas/GID" description: Access source ID to remove from scope - RemoveAccessReviewCampaignScopeSourceMCPOutput: + RemoveAccessReviewCampaignSourceMCPOutput: type: object required: - campaign @@ -13310,39 +13310,39 @@ tools: $ref: "#/components/schemas/ListAccessEntriesInput" outputSchema: $ref: "#/components/schemas/ListAccessEntriesOutput" - - name: getAccessReviewCampaignStatistics + - name: getAccessReviewStatistics description: Get statistics for an access review campaign including counts by decision, flag, and incremental tag hints: readonly: true idempotent: true inputSchema: - $ref: "#/components/schemas/GetAccessReviewCampaignStatisticsInput" + $ref: "#/components/schemas/GetAccessReviewStatisticsInput" outputSchema: - $ref: "#/components/schemas/GetAccessReviewCampaignStatisticsOutput" - - name: recordAccessEntryDecision + $ref: "#/components/schemas/GetAccessReviewStatisticsOutput" + - name: recordAccessReviewEntryDecision description: Record a decision on an access entry (APPROVED, REVOKE, DEFER, or ESCALATE). Non-APPROVED decisions require a decision_note. hints: readonly: false inputSchema: - $ref: "#/components/schemas/RecordAccessEntryDecisionMCPInput" + $ref: "#/components/schemas/RecordAccessReviewEntryDecisionMCPInput" outputSchema: - $ref: "#/components/schemas/RecordAccessEntryDecisionMCPOutput" - - name: recordAccessEntryDecisions + $ref: "#/components/schemas/RecordAccessReviewEntryDecisionMCPOutput" + - name: recordAccessReviewEntryDecisions description: Record decisions on multiple access entries in a single batch. Non-APPROVED decisions require a decision_note. hints: readonly: false inputSchema: - $ref: "#/components/schemas/RecordAccessEntryDecisionsMCPInput" + $ref: "#/components/schemas/RecordAccessReviewEntryDecisionsMCPInput" outputSchema: - $ref: "#/components/schemas/RecordAccessEntryDecisionsMCPOutput" - - name: flagAccessEntry + $ref: "#/components/schemas/RecordAccessReviewEntryDecisionsMCPOutput" + - name: flagAccessReviewEntry description: Flag an access entry with one or more flags during review (ORPHANED, INACTIVE, EXCESSIVE, ROLE_MISMATCH, NEW, etc.). Optionally provide reasons. hints: readonly: false inputSchema: - $ref: "#/components/schemas/FlagAccessEntryMCPInput" + $ref: "#/components/schemas/FlagAccessReviewEntryMCPInput" outputSchema: - $ref: "#/components/schemas/FlagAccessEntryMCPOutput" + $ref: "#/components/schemas/FlagAccessReviewEntryMCPOutput" - name: closeAccessReviewCampaign description: Close an access review campaign. All entries must have been decided (no PENDING entries). hints: @@ -13351,40 +13351,40 @@ tools: $ref: "#/components/schemas/CloseAccessReviewCampaignMCPInput" outputSchema: $ref: "#/components/schemas/CloseAccessReviewCampaignMCPOutput" - - name: listAccessSources + - name: listAccessReviewSources description: List access sources for an organization hints: readonly: true idempotent: true inputSchema: - $ref: "#/components/schemas/ListAccessSourcesInput" + $ref: "#/components/schemas/ListAccessReviewSourcesInput" outputSchema: - $ref: "#/components/schemas/ListAccessSourcesOutput" - - name: createAccessSource + $ref: "#/components/schemas/ListAccessReviewSourcesOutput" + - name: createAccessReviewSource description: Create a new access source for an organization hints: readonly: false inputSchema: - $ref: "#/components/schemas/CreateAccessSourceMCPInput" + $ref: "#/components/schemas/CreateAccessReviewSourceMCPInput" outputSchema: - $ref: "#/components/schemas/CreateAccessSourceMCPOutput" - - name: updateAccessSource + $ref: "#/components/schemas/CreateAccessReviewSourceMCPOutput" + - name: updateAccessReviewSource description: Update an existing access source hints: readonly: false inputSchema: - $ref: "#/components/schemas/UpdateAccessSourceMCPInput" + $ref: "#/components/schemas/UpdateAccessReviewSourceMCPInput" outputSchema: - $ref: "#/components/schemas/UpdateAccessSourceMCPOutput" - - name: deleteAccessSource + $ref: "#/components/schemas/UpdateAccessReviewSourceMCPOutput" + - name: deleteAccessReviewSource description: Delete an access source hints: readonly: false destructive: true inputSchema: - $ref: "#/components/schemas/DeleteAccessSourceMCPInput" + $ref: "#/components/schemas/DeleteAccessReviewSourceMCPInput" outputSchema: - $ref: "#/components/schemas/DeleteAccessSourceMCPOutput" + $ref: "#/components/schemas/DeleteAccessReviewSourceMCPOutput" - name: createAccessReviewCampaign description: Create a new access review campaign for an organization hints: @@ -13426,22 +13426,22 @@ tools: $ref: "#/components/schemas/CancelAccessReviewCampaignMCPInput" outputSchema: $ref: "#/components/schemas/CancelAccessReviewCampaignMCPOutput" - - name: addAccessReviewCampaignScopeSource + - name: addAccessReviewCampaignSource description: Add an access source to an access review campaign's scope hints: readonly: false inputSchema: - $ref: "#/components/schemas/AddAccessReviewCampaignScopeSourceMCPInput" + $ref: "#/components/schemas/AddAccessReviewCampaignSourceMCPInput" outputSchema: - $ref: "#/components/schemas/AddAccessReviewCampaignScopeSourceMCPOutput" - - name: removeAccessReviewCampaignScopeSource + $ref: "#/components/schemas/AddAccessReviewCampaignSourceMCPOutput" + - name: removeAccessReviewCampaignSource description: Remove an access source from an access review campaign's scope hints: readonly: false inputSchema: - $ref: "#/components/schemas/RemoveAccessReviewCampaignScopeSourceMCPInput" + $ref: "#/components/schemas/RemoveAccessReviewCampaignSourceMCPInput" outputSchema: - $ref: "#/components/schemas/RemoveAccessReviewCampaignScopeSourceMCPOutput" + $ref: "#/components/schemas/RemoveAccessReviewCampaignSourceMCPOutput" - name: getOrganizationContext description: Get the organization context containing structured sections about the company hints: diff --git a/pkg/server/api/mcp/v1/types/access_review.go b/pkg/server/api/mcp/v1/types/access_review.go index 3b87c0598..bad36d688 100644 --- a/pkg/server/api/mcp/v1/types/access_review.go +++ b/pkg/server/api/mcp/v1/types/access_review.go @@ -19,8 +19,8 @@ import ( "go.probo.inc/probo/pkg/page" ) -func NewAccessSource(s *coredata.AccessSource) *AccessSource { - return &AccessSource{ +func NewAccessReviewSource(s *coredata.AccessReviewSource) *AccessReviewSource { + return &AccessReviewSource{ ID: s.ID, OrganizationID: s.OrganizationID, ConnectorID: s.ConnectorID, @@ -31,12 +31,12 @@ func NewAccessSource(s *coredata.AccessSource) *AccessSource { } } -func NewListAccessSourcesOutput( - p *page.Page[*coredata.AccessSource, coredata.AccessSourceOrderField], -) ListAccessSourcesOutput { - sources := make([]*AccessSource, 0, len(p.Data)) +func NewListAccessReviewSourcesOutput( + p *page.Page[*coredata.AccessReviewSource, coredata.AccessReviewSourceOrderField], +) ListAccessReviewSourcesOutput { + sources := make([]*AccessReviewSource, 0, len(p.Data)) for _, s := range p.Data { - sources = append(sources, NewAccessSource(s)) + sources = append(sources, NewAccessReviewSource(s)) } var nextCursor *page.CursorKey @@ -46,9 +46,9 @@ func NewListAccessSourcesOutput( nextCursor = &cursorKey } - return ListAccessSourcesOutput{ - NextCursor: nextCursor, - AccessSources: sources, + return ListAccessReviewSourcesOutput{ + NextCursor: nextCursor, + AccessReviewSources: sources, } } @@ -67,32 +67,32 @@ func NewAccessReviewCampaign(c *coredata.AccessReviewCampaign) *AccessReviewCamp } } -func NewAccessEntry(e *coredata.AccessEntry) *AccessEntry { - entry := &AccessEntry{ - ID: e.ID, - CampaignID: e.AccessReviewCampaignID, - AccessSourceID: e.AccessSourceID, - Email: e.Email, - FullName: e.FullName, - Role: e.Role, - JobTitle: e.JobTitle, - IsAdmin: e.IsAdmin, - Active: e.Active, - MfaStatus: e.MFAStatus, - AuthMethod: e.AuthMethod, - AccountType: e.AccountType, - LastLogin: e.LastLogin, - AccountCreatedAt: e.AccountCreatedAt, - ExternalID: e.ExternalID, - IncrementalTag: e.IncrementalTag, - Flags: e.Flags, - FlagReasons: e.FlagReasons, - Decision: e.Decision, - DecisionNote: e.DecisionNote, - DecidedBy: e.DecidedBy, - DecidedAt: e.DecidedAt, - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, +func NewAccessReviewEntry(e *coredata.AccessReviewEntry) *AccessReviewEntry { + entry := &AccessReviewEntry{ + ID: e.ID, + CampaignID: e.AccessReviewCampaignID, + AccessReviewCampaignSourceID: e.AccessReviewCampaignSourceID, + Email: e.Email, + FullName: e.FullName, + Role: e.Role, + JobTitle: e.JobTitle, + IsAdmin: e.IsAdmin, + Active: e.Active, + MfaStatus: e.MFAStatus, + AuthMethod: e.AuthMethod, + AccountType: e.AccountType, + LastLogin: e.LastLogin, + AccountCreatedAt: e.AccountCreatedAt, + ExternalID: e.ExternalID, + IncrementalTag: e.IncrementalTag, + Flags: e.Flags, + FlagReasons: e.FlagReasons, + Decision: e.Decision, + DecisionNote: e.DecisionNote, + DecidedBy: e.DecidedBy, + DecidedAt: e.DecidedAt, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, } return entry @@ -120,11 +120,11 @@ func NewListAccessReviewCampaignsOutput( } func NewListAccessEntriesOutput( - p *page.Page[*coredata.AccessEntry, coredata.AccessEntryOrderField], + p *page.Page[*coredata.AccessReviewEntry, coredata.AccessReviewEntryOrderField], ) ListAccessEntriesOutput { - entries := make([]*AccessEntry, 0, len(p.Data)) + entries := make([]*AccessReviewEntry, 0, len(p.Data)) for _, e := range p.Data { - entries = append(entries, NewAccessEntry(e)) + entries = append(entries, NewAccessReviewEntry(e)) } var nextCursor *page.CursorKey @@ -140,7 +140,7 @@ func NewListAccessEntriesOutput( } } -func NewAccessEntryStatistics(s *coredata.AccessEntryStatistics) *AccessEntryStatistics { +func NewAccessReviewStatistics(s *coredata.AccessReviewStatistics) *AccessReviewStatistics { decisionCounts := make(map[string]any, len(s.DecisionCounts)) for k, v := range s.DecisionCounts { decisionCounts[string(k)] = v @@ -156,7 +156,7 @@ func NewAccessEntryStatistics(s *coredata.AccessEntryStatistics) *AccessEntrySta incrementalTagCounts[string(k)] = v } - return &AccessEntryStatistics{ + return &AccessReviewStatistics{ TotalCount: s.TotalCount, DecisionCounts: decisionCounts, FlagCounts: flagCounts,