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,