Introduce access-review source snapshot and normalize naming
Decouple each campaign from the live access-review sources it was started with by introducing a per-campaign source snapshot table (access_review_campaign_sources). The snapshot captures the source name, category, and connector at start time, so a review remains coherent even after the underlying source is edited or deleted. Fetch tracking becomes an append-only log (access_review_campaign_source_fetch_attempts) that preserves every attempt with its own status and error rather than overwriting a single row. Rename the shared access-review tables and enums to use a consistent access_review_ prefix throughout: access_entries → access_review_entries access_sources → access_review_sources access_source_category → access_review_source_category access_entry_* → access_review_entry_* The same rename propagates to every coredata type, service, GraphQL schema, MCP specification, CLI command, frontend component, and e2e test. The accessreview package gains dedicated actions.go and policies.go files for its own IAM policy set, mirroring the agentrun package pattern. Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
@@ -69,7 +69,7 @@ const fragment = graphql`
|
|||||||
action: "core:statement-of-applicability:list"
|
action: "core:statement-of-applicability:list"
|
||||||
)
|
)
|
||||||
canListAccessReviewCampaigns: permission(
|
canListAccessReviewCampaigns: permission(
|
||||||
action: "core:access-review-campaign:list"
|
action: "access-review:campaign:list"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -27,20 +27,20 @@ import { Link, useNavigate } from "react-router";
|
|||||||
import { ConnectionHandler, graphql } from "relay-runtime";
|
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { accessSourceMutationsCreateMutation } from "#/__generated__/core/accessSourceMutationsCreateMutation.graphql";
|
import type { accessReviewSourceMutationsCreateMutation } from "#/__generated__/core/accessReviewSourceMutationsCreateMutation.graphql";
|
||||||
import type { CreateCsvAccessSourcePageQuery } from "#/__generated__/core/CreateCsvAccessSourcePageQuery.graphql";
|
import type { CreateCsvAccessReviewSourcePageQuery } from "#/__generated__/core/CreateCsvAccessReviewSourcePageQuery.graphql";
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
import { createAccessSourceMutation } from "./dialogs/accessSourceMutations";
|
import { createAccessReviewSourceMutation } from "./dialogs/accessReviewSourceMutations";
|
||||||
|
|
||||||
export const createCsvAccessSourcePageQuery = graphql`
|
export const createCsvAccessReviewSourcePageQuery = graphql`
|
||||||
query CreateCsvAccessSourcePageQuery($organizationId: ID!) {
|
query CreateCsvAccessReviewSourcePageQuery($organizationId: ID!) {
|
||||||
organization: node(id: $organizationId) {
|
organization: node(id: $organizationId) {
|
||||||
__typename
|
__typename
|
||||||
... on Organization {
|
... on Organization {
|
||||||
id
|
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),
|
csvData: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function CreateCsvAccessSourcePage({
|
export default function CreateCsvAccessReviewSourcePage({
|
||||||
queryRef,
|
queryRef,
|
||||||
}: {
|
}: {
|
||||||
queryRef: PreloadedQuery<CreateCsvAccessSourcePageQuery>;
|
queryRef: PreloadedQuery<CreateCsvAccessReviewSourcePageQuery>;
|
||||||
}) {
|
}) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -70,19 +70,19 @@ export default function CreateCsvAccessSourcePage({
|
|||||||
|
|
||||||
usePageTitle(__("Add CSV Access Source"));
|
usePageTitle(__("Add CSV Access Source"));
|
||||||
|
|
||||||
const { organization } = usePreloadedQuery(createCsvAccessSourcePageQuery, queryRef);
|
const { organization } = usePreloadedQuery(createCsvAccessReviewSourcePageQuery, queryRef);
|
||||||
if (organization.__typename !== "Organization") {
|
if (organization.__typename !== "Organization") {
|
||||||
throw new Error("Organization not found");
|
throw new Error("Organization not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionId = ConnectionHandler.getConnectionID(
|
const connectionId = ConnectionHandler.getConnectionID(
|
||||||
organization.id,
|
organization.id,
|
||||||
"AccessReviewSourcesTab_accessSources",
|
"AccessReviewSourcesTab_accessReviewSources",
|
||||||
);
|
);
|
||||||
|
|
||||||
const [createAccessSource, isCreating]
|
const [createAccessReviewSource, isCreating]
|
||||||
= useMutation<accessSourceMutationsCreateMutation>(
|
= useMutation<accessReviewSourceMutationsCreateMutation>(
|
||||||
createAccessSourceMutation,
|
createAccessReviewSourceMutation,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!organization.canCreateSource) {
|
if (!organization.canCreateSource) {
|
||||||
@@ -96,7 +96,7 @@ export default function CreateCsvAccessSourcePage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = (data: z.infer<typeof csvSchema>) => {
|
const onSubmit = (data: z.infer<typeof csvSchema>) => {
|
||||||
createAccessSource({
|
createAccessReviewSource({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -15,16 +15,16 @@
|
|||||||
import { Suspense, useEffect } from "react";
|
import { Suspense, useEffect } from "react";
|
||||||
import { useQueryLoader } from "react-relay";
|
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 { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
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 organizationId = useOrganizationId();
|
||||||
const [queryRef, loadQuery]
|
const [queryRef, loadQuery]
|
||||||
= useQueryLoader<CreateCsvAccessSourcePageQuery>(createCsvAccessSourcePageQuery);
|
= useQueryLoader<CreateCsvAccessReviewSourcePageQuery>(createCsvAccessReviewSourcePageQuery);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadQuery({ organizationId });
|
loadQuery({ organizationId });
|
||||||
@@ -36,7 +36,7 @@ export default function CreateCsvAccessSourcePageLoader() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<PageSkeleton />}>
|
<Suspense fallback={<PageSkeleton />}>
|
||||||
<CreateCsvAccessSourcePage queryRef={queryRef} />
|
<CreateCsvAccessReviewSourcePage queryRef={queryRef} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -32,13 +32,13 @@ import { Suspense, useState } from "react";
|
|||||||
import { useFragment, useLazyLoadQuery, useMutation } from "react-relay";
|
import { useFragment, useLazyLoadQuery, useMutation } from "react-relay";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { AccessSourceRowConfigureMutation } from "#/__generated__/core/AccessSourceRowConfigureMutation.graphql";
|
import type { AccessReviewSourceRowConfigureMutation } from "#/__generated__/core/AccessReviewSourceRowConfigureMutation.graphql";
|
||||||
import type { AccessSourceRowDeleteMutation } from "#/__generated__/core/AccessSourceRowDeleteMutation.graphql";
|
import type { AccessReviewSourceRowDeleteMutation } from "#/__generated__/core/AccessReviewSourceRowDeleteMutation.graphql";
|
||||||
import type { AccessSourceRowFragment$key } from "#/__generated__/core/AccessSourceRowFragment.graphql";
|
import type { AccessReviewSourceRowFragment$key } from "#/__generated__/core/AccessReviewSourceRowFragment.graphql";
|
||||||
import type { AccessSourceRowOrgsQuery } from "#/__generated__/core/AccessSourceRowOrgsQuery.graphql";
|
import type { AccessReviewSourceRowOrgsQuery } from "#/__generated__/core/AccessReviewSourceRowOrgsQuery.graphql";
|
||||||
|
|
||||||
const fragment = graphql`
|
const fragment = graphql`
|
||||||
fragment AccessSourceRowFragment on AccessSource {
|
fragment AccessReviewSourceRowFragment on AccessReviewSource {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
connectorId
|
connectorId
|
||||||
@@ -50,27 +50,27 @@ const fragment = graphql`
|
|||||||
selectedOrganization
|
selectedOrganization
|
||||||
needsConfiguration
|
needsConfiguration
|
||||||
createdAt
|
createdAt
|
||||||
canDelete: permission(action: "core:access-source:delete")
|
canDelete: permission(action: "access-review:source:delete")
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const deleteAccessSourceMutation = graphql`
|
export const deleteAccessReviewSourceMutation = graphql`
|
||||||
mutation AccessSourceRowDeleteMutation(
|
mutation AccessReviewSourceRowDeleteMutation(
|
||||||
$input: DeleteAccessSourceInput!
|
$input: DeleteAccessReviewSourceInput!
|
||||||
$connections: [ID!]!
|
$connections: [ID!]!
|
||||||
) {
|
) {
|
||||||
deleteAccessSource(input: $input) {
|
deleteAccessReviewSource(input: $input) {
|
||||||
deletedAccessSourceId @deleteEdge(connections: $connections)
|
deletedAccessReviewSourceId @deleteEdge(connections: $connections)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const configureMutation = graphql`
|
const configureMutation = graphql`
|
||||||
mutation AccessSourceRowConfigureMutation(
|
mutation AccessReviewSourceRowConfigureMutation(
|
||||||
$input: ConfigureAccessSourceInput!
|
$input: ConfigureAccessReviewSourceInput!
|
||||||
) {
|
) {
|
||||||
configureAccessSource(input: $input) {
|
configureAccessReviewSource(input: $input) {
|
||||||
accessSource {
|
accessReviewSource {
|
||||||
id
|
id
|
||||||
selectedOrganization
|
selectedOrganization
|
||||||
needsConfiguration
|
needsConfiguration
|
||||||
@@ -80,9 +80,9 @@ const configureMutation = graphql`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const orgsQuery = graphql`
|
const orgsQuery = graphql`
|
||||||
query AccessSourceRowOrgsQuery($accessSourceId: ID!) {
|
query AccessReviewSourceRowOrgsQuery($accessReviewSourceId: ID!) {
|
||||||
node(id: $accessSourceId) @required(action: THROW) {
|
node(id: $accessReviewSourceId) @required(action: THROW) {
|
||||||
... on AccessSource {
|
... on AccessReviewSource {
|
||||||
providerOrganizations {
|
providerOrganizations {
|
||||||
slug
|
slug
|
||||||
displayName
|
displayName
|
||||||
@@ -93,7 +93,7 @@ const orgsQuery = graphql`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
fKey: AccessSourceRowFragment$key;
|
fKey: AccessReviewSourceRowFragment$key;
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
organizationId: 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 { __ } = useTranslate();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const accessSource = useFragment(fragment, fKey);
|
const accessSource = useFragment(fragment, fKey);
|
||||||
|
|
||||||
const [deleteAccessSource] = useMutation<AccessSourceRowDeleteMutation>(deleteAccessSourceMutation);
|
const [deleteAccessReviewSource] = useMutation<AccessReviewSourceRowDeleteMutation>(deleteAccessReviewSourceMutation);
|
||||||
const [configure] = useMutation<AccessSourceRowConfigureMutation>(configureMutation);
|
const [configure] = useMutation<AccessReviewSourceRowConfigureMutation>(configureMutation);
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
confirm(
|
confirm(
|
||||||
() => {
|
() => {
|
||||||
deleteAccessSource({
|
deleteAccessReviewSource({
|
||||||
variables: {
|
variables: {
|
||||||
input: { accessSourceId: accessSource.id },
|
input: { accessReviewSourceId: accessSource.id },
|
||||||
connections: [connectionId],
|
connections: [connectionId],
|
||||||
},
|
},
|
||||||
onCompleted: (_response, errors) => {
|
onCompleted: (_response, errors) => {
|
||||||
@@ -176,7 +176,7 @@ export function AccessSourceRow({ fKey, connectionId, organizationId }: Props) {
|
|||||||
configure({
|
configure({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
accessSourceId: accessSource.id,
|
accessReviewSourceId: accessSource.id,
|
||||||
organizationSlug: slug,
|
organizationSlug: slug,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -261,7 +261,7 @@ export function AccessSourceRow({ fKey, connectionId, organizationId }: Props) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<InlineOrgSelect
|
<InlineOrgSelect
|
||||||
accessSourceId={accessSource.id}
|
accessReviewSourceId={accessSource.id}
|
||||||
selectedOrganization={accessSource.selectedOrganization ?? ""}
|
selectedOrganization={accessSource.selectedOrganization ?? ""}
|
||||||
onSelect={handleOrgChange}
|
onSelect={handleOrgChange}
|
||||||
/>
|
/>
|
||||||
@@ -295,18 +295,18 @@ export function AccessSourceRow({ fKey, connectionId, organizationId }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function InlineOrgSelect({
|
function InlineOrgSelect({
|
||||||
accessSourceId,
|
accessReviewSourceId,
|
||||||
selectedOrganization,
|
selectedOrganization,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
accessSourceId: string;
|
accessReviewSourceId: string;
|
||||||
selectedOrganization: string;
|
selectedOrganization: string;
|
||||||
onSelect: (slug: string) => void;
|
onSelect: (slug: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const data = useLazyLoadQuery<AccessSourceRowOrgsQuery>(
|
const data = useLazyLoadQuery<AccessReviewSourceRowOrgsQuery>(
|
||||||
orgsQuery,
|
orgsQuery,
|
||||||
{ accessSourceId },
|
{ accessReviewSourceId },
|
||||||
{ fetchPolicy: "store-or-network" },
|
{ fetchPolicy: "store-or-network" },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -31,16 +31,16 @@ import { useState } from "react";
|
|||||||
import { useMutation } from "react-relay";
|
import { useMutation } from "react-relay";
|
||||||
import { graphql } from "relay-runtime";
|
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";
|
import { decisionBadgeVariant, decisionLabel } from "./accessReviewHelpers";
|
||||||
|
|
||||||
const mutation = graphql`
|
const mutation = graphql`
|
||||||
mutation EntryDecisionActionsMutation(
|
mutation EntryDecisionActionsMutation(
|
||||||
$input: RecordAccessEntryDecisionInput!
|
$input: RecordAccessReviewEntryDecisionInput!
|
||||||
) {
|
) {
|
||||||
recordAccessEntryDecision(input: $input) {
|
recordAccessReviewEntryDecision(input: $input) {
|
||||||
accessEntry {
|
accessReviewEntry {
|
||||||
id
|
id
|
||||||
decision
|
decision
|
||||||
decisionNote
|
decisionNote
|
||||||
@@ -59,16 +59,16 @@ export function EntryDecisionActions({ entryId, decision }: Props) {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const ref = useDialogRef();
|
const ref = useDialogRef();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [pendingDecision, setPendingDecision] = useState<AccessEntryDecision | null>(null);
|
const [pendingDecision, setPendingDecision] = useState<AccessReviewEntryDecision | null>(null);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [recordDecision, isRecording]
|
const [recordDecision, isRecording]
|
||||||
= useMutation<EntryDecisionActionsMutation>(mutation);
|
= useMutation<EntryDecisionActionsMutation>(mutation);
|
||||||
|
|
||||||
const submitDecision = (decisionValue: AccessEntryDecision, decisionNote?: string) => {
|
const submitDecision = (decisionValue: AccessReviewEntryDecision, decisionNote?: string) => {
|
||||||
recordDecision({
|
recordDecision({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
accessEntryId: entryId,
|
accessReviewEntryId: entryId,
|
||||||
decision: decisionValue,
|
decision: decisionValue,
|
||||||
decisionNote: decisionNote || null,
|
decisionNote: decisionNote || null,
|
||||||
},
|
},
|
||||||
@@ -103,14 +103,14 @@ export function EntryDecisionActions({ entryId, decision }: Props) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const openNoteDialog = (decisionValue: AccessEntryDecision) => {
|
const openNoteDialog = (decisionValue: AccessReviewEntryDecision) => {
|
||||||
setPendingDecision(decisionValue);
|
setPendingDecision(decisionValue);
|
||||||
setNote("");
|
setNote("");
|
||||||
ref.current?.open();
|
ref.current?.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDecision = (value: string) => {
|
const handleDecision = (value: string) => {
|
||||||
const decision = value as AccessEntryDecision;
|
const decision = value as AccessReviewEntryDecision;
|
||||||
if (decision === "APPROVED") {
|
if (decision === "APPROVED") {
|
||||||
submitDecision(decision);
|
submitDecision(decision);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ import { useRef, useState } from "react";
|
|||||||
import { useMutation } from "react-relay";
|
import { useMutation } from "react-relay";
|
||||||
import { graphql } from "relay-runtime";
|
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";
|
import { flagBadgeVariant, flagGroups, flagLabel } from "./accessReviewHelpers";
|
||||||
|
|
||||||
const mutation = graphql`
|
const mutation = graphql`
|
||||||
mutation EntryFlagSelectMutation($input: FlagAccessEntryInput!) {
|
mutation EntryFlagSelectMutation($input: FlagAccessReviewEntryInput!) {
|
||||||
flagAccessEntry(input: $input) {
|
flagAccessReviewEntry(input: $input) {
|
||||||
accessEntry {
|
accessReviewEntry {
|
||||||
id
|
id
|
||||||
flags
|
flags
|
||||||
flagReasons
|
flagReasons
|
||||||
@@ -38,18 +38,18 @@ const mutation = graphql`
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
entryId: string;
|
entryId: string;
|
||||||
currentFlags: readonly AccessEntryFlag[];
|
currentFlags: readonly AccessReviewEntryFlag[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EntryFlagSelect({ entryId, currentFlags }: Props) {
|
export function EntryFlagSelect({ entryId, currentFlags }: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [localFlags, setLocalFlags] = useState<AccessEntryFlag[]>([...currentFlags]);
|
const [localFlags, setLocalFlags] = useState<AccessReviewEntryFlag[]>([...currentFlags]);
|
||||||
const openedWithRef = useRef<readonly AccessEntryFlag[]>(currentFlags);
|
const openedWithRef = useRef<readonly AccessReviewEntryFlag[]>(currentFlags);
|
||||||
const [flagEntry] = useMutation<EntryFlagSelectMutation>(mutation);
|
const [flagEntry] = useMutation<EntryFlagSelectMutation>(mutation);
|
||||||
|
|
||||||
const toggleFlag = (flagValue: AccessEntryFlag) => {
|
const toggleFlag = (flagValue: AccessReviewEntryFlag) => {
|
||||||
setLocalFlags(prev =>
|
setLocalFlags(prev =>
|
||||||
prev.includes(flagValue)
|
prev.includes(flagValue)
|
||||||
? prev.filter(f => f !== flagValue)
|
? prev.filter(f => f !== flagValue)
|
||||||
@@ -73,7 +73,7 @@ export function EntryFlagSelect({ entryId, currentFlags }: Props) {
|
|||||||
flagEntry({
|
flagEntry({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
accessEntryId: entryId,
|
accessReviewEntryId: entryId,
|
||||||
flags: localFlags,
|
flags: localFlags,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(
|
export function statusLabel(
|
||||||
__: (key: string) => string,
|
__: (key: string) => string,
|
||||||
status: string,
|
status: string,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const accessReviewCampaignsTabQuery = graphql`
|
|||||||
organization: node(id: $organizationId) {
|
organization: node(id: $organizationId) {
|
||||||
__typename
|
__typename
|
||||||
... on Organization {
|
... on Organization {
|
||||||
canCreateCampaign: permission(action: "core:access-review-campaign:create")
|
canCreateCampaign: permission(action: "access-review:campaign:create")
|
||||||
...AccessReviewCampaignsTabFragment
|
...AccessReviewCampaignsTabFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ const campaignsFragment = graphql`
|
|||||||
name
|
name
|
||||||
status
|
status
|
||||||
createdAt
|
createdAt
|
||||||
canDelete: permission(action: "core:access-review-campaign:delete")
|
canDelete: permission(action: "access-review:campaign:delete")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
IconPlusLarge,
|
IconPlusLarge,
|
||||||
IconRobot,
|
IconRobot,
|
||||||
IconTrashCan,
|
IconTrashCan,
|
||||||
|
IconWarning,
|
||||||
Option,
|
Option,
|
||||||
Select,
|
Select,
|
||||||
Tbody,
|
Tbody,
|
||||||
@@ -47,8 +48,8 @@ import { type PreloadedQuery, useMutation, usePreloadedQuery, useRelayEnvironmen
|
|||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { ConnectionHandler, fetchQuery, graphql } from "relay-runtime";
|
import { ConnectionHandler, fetchQuery, graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { AccessEntryDecision, CampaignDetailPageBulkDecisionMutation } from "#/__generated__/core/CampaignDetailPageBulkDecisionMutation.graphql";
|
import type { AccessReviewEntryDecision, CampaignDetailPageBulkDecisionMutation } from "#/__generated__/core/CampaignDetailPageBulkDecisionMutation.graphql";
|
||||||
import type { AccessEntryFlag, CampaignDetailPageBulkFlagMutation } from "#/__generated__/core/CampaignDetailPageBulkFlagMutation.graphql";
|
import type { AccessReviewEntryFlag, CampaignDetailPageBulkFlagMutation } from "#/__generated__/core/CampaignDetailPageBulkFlagMutation.graphql";
|
||||||
import type { CampaignDetailPageCloseMutation } from "#/__generated__/core/CampaignDetailPageCloseMutation.graphql";
|
import type { CampaignDetailPageCloseMutation } from "#/__generated__/core/CampaignDetailPageCloseMutation.graphql";
|
||||||
import type { CampaignDetailPageDeleteMutation } from "#/__generated__/core/CampaignDetailPageDeleteMutation.graphql";
|
import type { CampaignDetailPageDeleteMutation } from "#/__generated__/core/CampaignDetailPageDeleteMutation.graphql";
|
||||||
import type { CampaignDetailPageQuery } from "#/__generated__/core/CampaignDetailPageQuery.graphql";
|
import type { CampaignDetailPageQuery } from "#/__generated__/core/CampaignDetailPageQuery.graphql";
|
||||||
@@ -58,6 +59,7 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
|
|||||||
import {
|
import {
|
||||||
decisionBadgeVariant,
|
decisionBadgeVariant,
|
||||||
decisionLabel,
|
decisionLabel,
|
||||||
|
fetchStatusBadgeVariant,
|
||||||
flagBadgeVariant,
|
flagBadgeVariant,
|
||||||
flagGroups,
|
flagGroups,
|
||||||
flagLabel,
|
flagLabel,
|
||||||
@@ -68,7 +70,7 @@ import {
|
|||||||
} from "../_components/accessReviewHelpers";
|
} from "../_components/accessReviewHelpers";
|
||||||
import { EntryDecisionActions } from "../_components/EntryDecisionActions";
|
import { EntryDecisionActions } from "../_components/EntryDecisionActions";
|
||||||
import { EntryFlagSelect } from "../_components/EntryFlagSelect";
|
import { EntryFlagSelect } from "../_components/EntryFlagSelect";
|
||||||
import { AddCampaignScopeSourceDialog } from "../dialogs/AddCampaignScopeSourceDialog";
|
import { AddCampaignSourceDialog } from "../dialogs/AddCampaignSourceDialog";
|
||||||
|
|
||||||
const startCampaignMutation = graphql`
|
const startCampaignMutation = graphql`
|
||||||
mutation CampaignDetailPageStartMutation(
|
mutation CampaignDetailPageStartMutation(
|
||||||
@@ -111,10 +113,10 @@ const deleteCampaignMutation = graphql`
|
|||||||
|
|
||||||
const bulkDecisionMutation = graphql`
|
const bulkDecisionMutation = graphql`
|
||||||
mutation CampaignDetailPageBulkDecisionMutation(
|
mutation CampaignDetailPageBulkDecisionMutation(
|
||||||
$input: RecordAccessEntryDecisionsInput!
|
$input: RecordAccessReviewEntryDecisionsInput!
|
||||||
) {
|
) {
|
||||||
recordAccessEntryDecisions(input: $input) {
|
recordAccessReviewEntryDecisions(input: $input) {
|
||||||
accessEntries {
|
accessReviewEntries {
|
||||||
id
|
id
|
||||||
decision
|
decision
|
||||||
decisionNote
|
decisionNote
|
||||||
@@ -125,10 +127,10 @@ const bulkDecisionMutation = graphql`
|
|||||||
|
|
||||||
const bulkFlagMutation = graphql`
|
const bulkFlagMutation = graphql`
|
||||||
mutation CampaignDetailPageBulkFlagMutation(
|
mutation CampaignDetailPageBulkFlagMutation(
|
||||||
$input: FlagAccessEntryInput!
|
$input: FlagAccessReviewEntryInput!
|
||||||
) {
|
) {
|
||||||
flagAccessEntry(input: $input) {
|
flagAccessReviewEntry(input: $input) {
|
||||||
accessEntry {
|
accessReviewEntry {
|
||||||
id
|
id
|
||||||
flags
|
flags
|
||||||
flagReasons
|
flagReasons
|
||||||
@@ -145,8 +147,8 @@ export const campaignDetailPageQuery = graphql`
|
|||||||
id
|
id
|
||||||
name
|
name
|
||||||
status
|
status
|
||||||
canDelete: permission(action: "core:access-review-campaign:delete")
|
canDelete: permission(action: "access-review:campaign:delete")
|
||||||
scopeSources {
|
sources {
|
||||||
id
|
id
|
||||||
source {
|
source {
|
||||||
id
|
id
|
||||||
@@ -154,6 +156,7 @@ export const campaignDetailPageQuery = graphql`
|
|||||||
name
|
name
|
||||||
fetchStatus
|
fetchStatus
|
||||||
fetchedAccountsCount
|
fetchedAccountsCount
|
||||||
|
lastError
|
||||||
entries(first: 500) {
|
entries(first: 500) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
@@ -222,9 +225,9 @@ export default function CampaignDetailPage({ queryRef }: Props) {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isInProgress, environment]);
|
}, [isInProgress, environment]);
|
||||||
const existingScopeSourceIds = useMemo(
|
const existingCampaignSourceIds = useMemo(
|
||||||
() => campaign.scopeSources.flatMap(s => s.source?.id ? [s.source.id] : []),
|
() => campaign.sources.flatMap(s => s.source?.id ? [s.source.id] : []),
|
||||||
[campaign.scopeSources],
|
[campaign.sources],
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
@@ -238,8 +241,8 @@ export default function CampaignDetailPage({ queryRef }: Props) {
|
|||||||
const [deleteCampaign, isDeleting]
|
const [deleteCampaign, isDeleting]
|
||||||
= useMutation<CampaignDetailPageDeleteMutation>(deleteCampaignMutation);
|
= useMutation<CampaignDetailPageDeleteMutation>(deleteCampaignMutation);
|
||||||
|
|
||||||
const allDecided = campaign.scopeSources.length > 0
|
const allDecided = campaign.sources.length > 0
|
||||||
&& campaign.scopeSources.every(source =>
|
&& campaign.sources.every(source =>
|
||||||
source.entries
|
source.entries
|
||||||
&& source.entries.edges.length > 0
|
&& source.entries.edges.length > 0
|
||||||
&& source.entries.edges.every(edge => edge.node.decision !== "PENDING")
|
&& source.entries.edges.every(edge => edge.node.decision !== "PENDING")
|
||||||
@@ -436,16 +439,16 @@ export default function CampaignDetailPage({ queryRef }: Props) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{isDraft && (
|
{isDraft && (
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<AddCampaignScopeSourceDialog
|
<AddCampaignSourceDialog
|
||||||
organizationId={organizationId}
|
organizationId={organizationId}
|
||||||
campaignId={campaign.id}
|
campaignId={campaign.id}
|
||||||
existingScopeSourceIds={existingScopeSourceIds}
|
existingCampaignSourceIds={existingCampaignSourceIds}
|
||||||
>
|
>
|
||||||
<Button icon={IconPlusLarge} variant="secondary">
|
<Button icon={IconPlusLarge} variant="secondary">
|
||||||
{__("Add source")}
|
{__("Add source")}
|
||||||
</Button>
|
</Button>
|
||||||
</AddCampaignScopeSourceDialog>
|
</AddCampaignSourceDialog>
|
||||||
{campaign.scopeSources.length > 0 && (
|
{campaign.sources.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
disabled={isStarting}
|
disabled={isStarting}
|
||||||
@@ -456,15 +459,15 @@ export default function CampaignDetailPage({ queryRef }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{campaign.scopeSources.map(source => (
|
{campaign.sources.map(source => (
|
||||||
<ScopeSourceCard
|
<CampaignSourceCard
|
||||||
key={source.id}
|
key={source.id}
|
||||||
source={source}
|
source={source}
|
||||||
isPendingActions={isPendingActions}
|
isPendingActions={isPendingActions}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{campaign.scopeSources.length === 0 && (
|
{campaign.sources.length === 0 && (
|
||||||
<Card padded>
|
<Card padded>
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<p className="text-txt-tertiary">
|
<p className="text-txt-tertiary">
|
||||||
@@ -478,19 +481,19 @@ export default function CampaignDetailPage({ queryRef }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScopeSource = NonNullable<
|
type CampaignSource = NonNullable<
|
||||||
Extract<
|
Extract<
|
||||||
CampaignDetailPageQuery["response"]["node"],
|
CampaignDetailPageQuery["response"]["node"],
|
||||||
{ readonly __typename: "AccessReviewCampaign" }
|
{ readonly __typename: "AccessReviewCampaign" }
|
||||||
>["scopeSources"]
|
>["sources"]
|
||||||
>[number];
|
>[number];
|
||||||
|
|
||||||
function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; isPendingActions: boolean }) {
|
function CampaignSourceCard({ source, isPendingActions }: { source: CampaignSource; isPendingActions: boolean }) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const { list: selection, toggle, clear, reset } = useList<string>([]);
|
const { list: selection, toggle, clear, reset } = useList<string>([]);
|
||||||
const [bulkPendingDecision, setBulkPendingDecision] = useState<AccessEntryDecision | null>(null);
|
const [bulkPendingDecision, setBulkPendingDecision] = useState<AccessReviewEntryDecision | null>(null);
|
||||||
const [bulkNote, setBulkNote] = useState("");
|
const [bulkNote, setBulkNote] = useState("");
|
||||||
const bulkNoteRef = useDialogRef();
|
const bulkNoteRef = useDialogRef();
|
||||||
|
|
||||||
@@ -503,14 +506,14 @@ function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; is
|
|||||||
const entryIds = entries.map(edge => edge.node.id);
|
const entryIds = entries.map(edge => edge.node.id);
|
||||||
|
|
||||||
const handleBulkDecision = (value: string) => {
|
const handleBulkDecision = (value: string) => {
|
||||||
const decision = value as AccessEntryDecision;
|
const decision = value as AccessReviewEntryDecision;
|
||||||
if (decision === "APPROVED") {
|
if (decision === "APPROVED") {
|
||||||
bulkDecide({
|
bulkDecide({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
decisions: selection.map(id => ({
|
decisions: selection.map(id => ({
|
||||||
accessEntryId: id,
|
accessReviewEntryId: id,
|
||||||
decision: "APPROVED" as AccessEntryDecision,
|
decision: "APPROVED" as AccessReviewEntryDecision,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -551,11 +554,11 @@ function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; is
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [bulkFlagSelection, setBulkFlagSelection] = useState<AccessEntryFlag[]>([]);
|
const [bulkFlagSelection, setBulkFlagSelection] = useState<AccessReviewEntryFlag[]>([]);
|
||||||
const [bulkFlagOpen, setBulkFlagOpen] = useState(false);
|
const [bulkFlagOpen, setBulkFlagOpen] = useState(false);
|
||||||
const bulkFlagOpenedWithRef = useRef<AccessEntryFlag[]>([]);
|
const bulkFlagOpenedWithRef = useRef<AccessReviewEntryFlag[]>([]);
|
||||||
|
|
||||||
const toggleBulkFlag = (flagValue: AccessEntryFlag) => {
|
const toggleBulkFlag = (flagValue: AccessReviewEntryFlag) => {
|
||||||
setBulkFlagSelection(prev =>
|
setBulkFlagSelection(prev =>
|
||||||
prev.includes(flagValue)
|
prev.includes(flagValue)
|
||||||
? prev.filter(f => f !== flagValue)
|
? prev.filter(f => f !== flagValue)
|
||||||
@@ -578,7 +581,7 @@ function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; is
|
|||||||
bulkFlag({
|
bulkFlag({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
accessEntryId: entryId,
|
accessReviewEntryId: entryId,
|
||||||
flags: bulkFlagSelection,
|
flags: bulkFlagSelection,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -635,17 +638,30 @@ function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; is
|
|||||||
? <IconChevronDown className="size-4 text-txt-tertiary" />
|
? <IconChevronDown className="size-4 text-txt-tertiary" />
|
||||||
: <IconChevronRight className="size-4 text-txt-tertiary" />}
|
: <IconChevronRight className="size-4 text-txt-tertiary" />}
|
||||||
<span className="font-medium">{source.name}</span>
|
<span className="font-medium">{source.name}</span>
|
||||||
|
{!source.source && (
|
||||||
|
<Badge variant="neutral">{__("Source deleted")}</Badge>
|
||||||
|
)}
|
||||||
<Badge variant="neutral">
|
<Badge variant="neutral">
|
||||||
{source.fetchedAccountsCount}
|
{source.fetchedAccountsCount}
|
||||||
{" "}
|
{" "}
|
||||||
{__("accounts")}
|
{__("accounts")}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant={source.fetchStatus === "SUCCESS" ? "success" : "info"}>
|
<Badge variant={fetchStatusBadgeVariant(source.fetchStatus)}>
|
||||||
{formatStatus(source.fetchStatus)}
|
{formatStatus(source.fetchStatus)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{source.fetchStatus === "FAILED" && source.lastError && (
|
||||||
|
<div className="flex items-start gap-2 border-t bg-danger px-4 py-3 text-sm text-txt-danger">
|
||||||
|
<IconWarning className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{__("Fetch failed")}</p>
|
||||||
|
<p>{source.lastError}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="border-t">
|
<div className="border-t">
|
||||||
{entries.length === 0
|
{entries.length === 0
|
||||||
@@ -841,7 +857,7 @@ function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; is
|
|||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
decisions: selection.map(id => ({
|
decisions: selection.map(id => ({
|
||||||
accessEntryId: id,
|
accessReviewEntryId: id,
|
||||||
decision: bulkPendingDecision,
|
decision: bulkPendingDecision,
|
||||||
decisionNote: bulkNote,
|
decisionNote: bulkNote,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -37,19 +37,19 @@ import { useMutation } from "react-relay";
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { accessSourceMutationsCreateMutation } from "#/__generated__/core/accessSourceMutationsCreateMutation.graphql";
|
import type { accessReviewSourceMutationsCreateMutation } from "#/__generated__/core/accessReviewSourceMutationsCreateMutation.graphql";
|
||||||
import type { AddAccessSourceDialogConnectorProviderInfoFragment$data } from "#/__generated__/core/AddAccessSourceDialogConnectorProviderInfoFragment.graphql";
|
import type { AddAccessReviewSourceDialogConnectorProviderInfoFragment$data } from "#/__generated__/core/AddAccessReviewSourceDialogConnectorProviderInfoFragment.graphql";
|
||||||
import type { AddAccessSourceDialogCreateAPIKeyConnectorMutation } from "#/__generated__/core/AddAccessSourceDialogCreateAPIKeyConnectorMutation.graphql";
|
import type { AddAccessReviewSourceDialogCreateAPIKeyConnectorMutation } from "#/__generated__/core/AddAccessReviewSourceDialogCreateAPIKeyConnectorMutation.graphql";
|
||||||
import type { AddAccessSourceDialogCreateClientCredentialsConnectorMutation } from "#/__generated__/core/AddAccessSourceDialogCreateClientCredentialsConnectorMutation.graphql";
|
import type { AddAccessReviewSourceDialogCreateClientCredentialsConnectorMutation } from "#/__generated__/core/AddAccessReviewSourceDialogCreateClientCredentialsConnectorMutation.graphql";
|
||||||
|
|
||||||
import { createAccessSourceMutation } from "./accessSourceMutations";
|
import { createAccessReviewSourceMutation } from "./accessReviewSourceMutations";
|
||||||
import {
|
import {
|
||||||
isPostHogDeploymentSelected,
|
isPostHogDeploymentSelected,
|
||||||
PostHogDeploymentField,
|
PostHogDeploymentField,
|
||||||
} from "./PostHogDeploymentField";
|
} from "./PostHogDeploymentField";
|
||||||
|
|
||||||
export const addAccessSourceDialogConnectorProviderInfoFragment = graphql`
|
export const addAccessReviewSourceDialogConnectorProviderInfoFragment = graphql`
|
||||||
fragment AddAccessSourceDialogConnectorProviderInfoFragment on ConnectorProviderInfo @relay(plural: true) {
|
fragment AddAccessReviewSourceDialogConnectorProviderInfoFragment on ConnectorProviderInfo @relay(plural: true) {
|
||||||
provider
|
provider
|
||||||
displayName
|
displayName
|
||||||
oauthConfigured
|
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),
|
// DATADOG_SITES labels are technical identifiers (region code + hostname),
|
||||||
// intentionally not wrapped in __(). The dialog's prose strings are.
|
// intentionally not wrapped in __(). The dialog's prose strings are.
|
||||||
@@ -87,7 +87,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createAPIKeyConnectorMutation = graphql`
|
const createAPIKeyConnectorMutation = graphql`
|
||||||
mutation AddAccessSourceDialogCreateAPIKeyConnectorMutation(
|
mutation AddAccessReviewSourceDialogCreateAPIKeyConnectorMutation(
|
||||||
$input: CreateAPIKeyConnectorInput!
|
$input: CreateAPIKeyConnectorInput!
|
||||||
) {
|
) {
|
||||||
createAPIKeyConnector(input: $input) {
|
createAPIKeyConnector(input: $input) {
|
||||||
@@ -100,7 +100,7 @@ const createAPIKeyConnectorMutation = graphql`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const createClientCredentialsConnectorMutation = graphql`
|
const createClientCredentialsConnectorMutation = graphql`
|
||||||
mutation AddAccessSourceDialogCreateClientCredentialsConnectorMutation(
|
mutation AddAccessReviewSourceDialogCreateClientCredentialsConnectorMutation(
|
||||||
$input: CreateClientCredentialsConnectorInput!
|
$input: CreateClientCredentialsConnectorInput!
|
||||||
) {
|
) {
|
||||||
createClientCredentialsConnector(input: $input) {
|
createClientCredentialsConnector(input: $input) {
|
||||||
@@ -199,7 +199,7 @@ function cleanZendeskSubdomain(raw: string): string {
|
|||||||
return value.trim();
|
return value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddAccessSourceDialog({
|
export function AddAccessReviewSourceDialog({
|
||||||
children,
|
children,
|
||||||
organizationId,
|
organizationId,
|
||||||
connectionId,
|
connectionId,
|
||||||
@@ -250,16 +250,16 @@ export function AddAccessSourceDialog({
|
|||||||
[existingSourceProviders],
|
[existingSourceProviders],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [createAccessSource]
|
const [createAccessReviewSource]
|
||||||
= useMutation<accessSourceMutationsCreateMutation>(
|
= useMutation<accessReviewSourceMutationsCreateMutation>(
|
||||||
createAccessSourceMutation,
|
createAccessReviewSourceMutation,
|
||||||
);
|
);
|
||||||
const [createAPIKeyConnector]
|
const [createAPIKeyConnector]
|
||||||
= useMutation<AddAccessSourceDialogCreateAPIKeyConnectorMutation>(
|
= useMutation<AddAccessReviewSourceDialogCreateAPIKeyConnectorMutation>(
|
||||||
createAPIKeyConnectorMutation,
|
createAPIKeyConnectorMutation,
|
||||||
);
|
);
|
||||||
const [createClientCredentialsConnector]
|
const [createClientCredentialsConnector]
|
||||||
= useMutation<AddAccessSourceDialogCreateClientCredentialsConnectorMutation>(
|
= useMutation<AddAccessReviewSourceDialogCreateClientCredentialsConnectorMutation>(
|
||||||
createClientCredentialsConnectorMutation,
|
createClientCredentialsConnectorMutation,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -320,7 +320,7 @@ export function AddAccessSourceDialog({
|
|||||||
displayName: string,
|
displayName: string,
|
||||||
onDone: () => void,
|
onDone: () => void,
|
||||||
) => {
|
) => {
|
||||||
createAccessSource({
|
createAccessReviewSource({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -28,17 +28,17 @@ import {
|
|||||||
import { type ReactNode, Suspense, useState } from "react";
|
import { type ReactNode, Suspense, useState } from "react";
|
||||||
import { graphql, useLazyLoadQuery, useMutation } from "react-relay";
|
import { graphql, useLazyLoadQuery, useMutation } from "react-relay";
|
||||||
|
|
||||||
import type { AddCampaignScopeSourceDialogMutation } from "#/__generated__/core/AddCampaignScopeSourceDialogMutation.graphql";
|
import type { AddCampaignSourceDialogMutation } from "#/__generated__/core/AddCampaignSourceDialogMutation.graphql";
|
||||||
import type { AddCampaignScopeSourceDialogSourcesQuery } from "#/__generated__/core/AddCampaignScopeSourceDialogSourcesQuery.graphql";
|
import type { AddCampaignSourceDialogSourcesQuery } from "#/__generated__/core/AddCampaignSourceDialogSourcesQuery.graphql";
|
||||||
|
|
||||||
const addScopeMutation = graphql`
|
const addScopeMutation = graphql`
|
||||||
mutation AddCampaignScopeSourceDialogMutation(
|
mutation AddCampaignSourceDialogMutation(
|
||||||
$input: AddAccessReviewCampaignScopeSourceInput!
|
$input: AddAccessReviewCampaignSourceInput!
|
||||||
) {
|
) {
|
||||||
addAccessReviewCampaignScopeSource(input: $input) {
|
addAccessReviewCampaignSource(input: $input) {
|
||||||
accessReviewCampaign {
|
accessReviewCampaign {
|
||||||
id
|
id
|
||||||
scopeSources {
|
sources {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
fetchStatus
|
fetchStatus
|
||||||
@@ -68,10 +68,10 @@ const addScopeMutation = graphql`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const sourcesQuery = graphql`
|
const sourcesQuery = graphql`
|
||||||
query AddCampaignScopeSourceDialogSourcesQuery($organizationId: ID!) {
|
query AddCampaignSourceDialogSourcesQuery($organizationId: ID!) {
|
||||||
organization: node(id: $organizationId) {
|
organization: node(id: $organizationId) {
|
||||||
... on Organization {
|
... on Organization {
|
||||||
accessSources(first: 100) {
|
accessReviewSources(first: 100) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
@@ -88,31 +88,31 @@ type Props = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
existingScopeSourceIds: string[];
|
existingCampaignSourceIds: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AddCampaignScopeSourceDialog({
|
export function AddCampaignSourceDialog({
|
||||||
children,
|
children,
|
||||||
organizationId,
|
organizationId,
|
||||||
campaignId,
|
campaignId,
|
||||||
existingScopeSourceIds,
|
existingCampaignSourceIds,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const ref = useDialogRef();
|
const ref = useDialogRef();
|
||||||
const [selectedSourceId, setSelectedSourceId] = useState<string>("");
|
const [selectedSourceId, setSelectedSourceId] = useState<string>("");
|
||||||
|
|
||||||
const [addScopeSource, isAdding]
|
const [addCampaignSource, isAdding]
|
||||||
= useMutation<AddCampaignScopeSourceDialogMutation>(addScopeMutation);
|
= useMutation<AddCampaignSourceDialogMutation>(addScopeMutation);
|
||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
if (!selectedSourceId) return;
|
if (!selectedSourceId) return;
|
||||||
|
|
||||||
addScopeSource({
|
addCampaignSource({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
accessReviewCampaignId: campaignId,
|
accessReviewCampaignId: campaignId,
|
||||||
accessSourceId: selectedSourceId,
|
accessReviewSourceId: selectedSourceId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onCompleted(_, errors) {
|
onCompleted(_, errors) {
|
||||||
@@ -164,7 +164,7 @@ export function AddCampaignScopeSourceDialog({
|
|||||||
>
|
>
|
||||||
<SourceSelect
|
<SourceSelect
|
||||||
organizationId={organizationId}
|
organizationId={organizationId}
|
||||||
existingScopeSourceIds={existingScopeSourceIds}
|
existingCampaignSourceIds={existingCampaignSourceIds}
|
||||||
value={selectedSourceId}
|
value={selectedSourceId}
|
||||||
onChange={setSelectedSourceId}
|
onChange={setSelectedSourceId}
|
||||||
/>
|
/>
|
||||||
@@ -184,29 +184,29 @@ export function AddCampaignScopeSourceDialog({
|
|||||||
|
|
||||||
function SourceSelect({
|
function SourceSelect({
|
||||||
organizationId,
|
organizationId,
|
||||||
existingScopeSourceIds,
|
existingCampaignSourceIds,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
existingScopeSourceIds: string[];
|
existingCampaignSourceIds: string[];
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const data
|
const data
|
||||||
= useLazyLoadQuery<AddCampaignScopeSourceDialogSourcesQuery>(
|
= useLazyLoadQuery<AddCampaignSourceDialogSourcesQuery>(
|
||||||
sourcesQuery,
|
sourcesQuery,
|
||||||
{ organizationId },
|
{ organizationId },
|
||||||
{ fetchPolicy: "network-only" },
|
{ fetchPolicy: "network-only" },
|
||||||
);
|
);
|
||||||
|
|
||||||
const sources
|
const sources
|
||||||
= data?.organization?.accessSources?.edges
|
= data?.organization?.accessReviewSources?.edges
|
||||||
?.map(edge => edge.node)
|
?.map(edge => edge.node)
|
||||||
.filter(
|
.filter(
|
||||||
(node): node is NonNullable<typeof node> =>
|
(node): node is NonNullable<typeof node> =>
|
||||||
node !== null && !existingScopeSourceIds.includes(node.id),
|
node !== null && !existingCampaignSourceIds.includes(node.id),
|
||||||
) ?? [];
|
) ?? [];
|
||||||
|
|
||||||
if (sources.length === 0) {
|
if (sources.length === 0) {
|
||||||
@@ -55,7 +55,7 @@ const sourcesQuery = graphql`
|
|||||||
query CreateAccessReviewCampaignDialogSourcesQuery($organizationId: ID!) {
|
query CreateAccessReviewCampaignDialogSourcesQuery($organizationId: ID!) {
|
||||||
organization: node(id: $organizationId) {
|
organization: node(id: $organizationId) {
|
||||||
... on Organization {
|
... on Organization {
|
||||||
accessSources(first: 500) {
|
accessReviewSources(first: 500) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
@@ -118,7 +118,7 @@ export function CreateAccessReviewCampaignDialog({
|
|||||||
organizationId,
|
organizationId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description || null,
|
description: data.description || null,
|
||||||
accessSourceIds:
|
accessReviewSourceIds:
|
||||||
selectedSourceIds.length > 0 ? selectedSourceIds : null,
|
selectedSourceIds.length > 0 ? selectedSourceIds : null,
|
||||||
},
|
},
|
||||||
connections: [connectionId],
|
connections: [connectionId],
|
||||||
@@ -227,7 +227,7 @@ function SourceSelector({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const sources
|
const sources
|
||||||
= data?.organization?.accessSources?.edges
|
= data?.organization?.accessReviewSources?.edges
|
||||||
?.map(edge => edge.node)
|
?.map(edge => edge.node)
|
||||||
.filter((node): node is NonNullable<typeof node> => node !== null) ?? [];
|
.filter((node): node is NonNullable<typeof node> => node !== null) ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -14,18 +14,18 @@
|
|||||||
|
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
export const createAccessSourceMutation = graphql`
|
export const createAccessReviewSourceMutation = graphql`
|
||||||
mutation accessSourceMutationsCreateMutation(
|
mutation accessReviewSourceMutationsCreateMutation(
|
||||||
$input: CreateAccessSourceInput!
|
$input: CreateAccessReviewSourceInput!
|
||||||
$connections: [ID!]!
|
$connections: [ID!]!
|
||||||
) {
|
) {
|
||||||
createAccessSource(input: $input) {
|
createAccessReviewSource(input: $input) {
|
||||||
accessSourceEdge @prependEdge(connections: $connections) {
|
accessReviewSourceEdge @prependEdge(connections: $connections) {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
createdAt
|
createdAt
|
||||||
...AccessSourceRowFragment
|
...AccessReviewSourceRowFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,23 +33,23 @@ import { useSearchParams } from "react-router";
|
|||||||
import type { AccessReviewSourcesTabFragment$key } from "#/__generated__/core/AccessReviewSourcesTabFragment.graphql";
|
import type { AccessReviewSourcesTabFragment$key } from "#/__generated__/core/AccessReviewSourcesTabFragment.graphql";
|
||||||
import type { AccessReviewSourcesTabPaginationQuery } from "#/__generated__/core/AccessReviewSourcesTabPaginationQuery.graphql";
|
import type { AccessReviewSourcesTabPaginationQuery } from "#/__generated__/core/AccessReviewSourcesTabPaginationQuery.graphql";
|
||||||
import type { AccessReviewSourcesTabQuery } from "#/__generated__/core/AccessReviewSourcesTabQuery.graphql";
|
import type { AccessReviewSourcesTabQuery } from "#/__generated__/core/AccessReviewSourcesTabQuery.graphql";
|
||||||
import type { accessSourceMutationsCreateMutation } from "#/__generated__/core/accessSourceMutationsCreateMutation.graphql";
|
import type { accessReviewSourceMutationsCreateMutation } from "#/__generated__/core/accessReviewSourceMutationsCreateMutation.graphql";
|
||||||
import type { AddAccessSourceDialogConnectorProviderInfoFragment$key } from "#/__generated__/core/AddAccessSourceDialogConnectorProviderInfoFragment.graphql";
|
import type { AddAccessReviewSourceDialogConnectorProviderInfoFragment$key } from "#/__generated__/core/AddAccessReviewSourceDialogConnectorProviderInfoFragment.graphql";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
import { AccessSourceRow } from "../_components/AccessSourceRow";
|
import { AccessReviewSourceRow } from "../_components/AccessReviewSourceRow";
|
||||||
import { createAccessSourceMutation } from "../dialogs/accessSourceMutations";
|
import { createAccessReviewSourceMutation } from "../dialogs/accessReviewSourceMutations";
|
||||||
import { AddAccessSourceDialog, addAccessSourceDialogConnectorProviderInfoFragment } from "../dialogs/AddAccessSourceDialog";
|
import { AddAccessReviewSourceDialog, addAccessReviewSourceDialogConnectorProviderInfoFragment } from "../dialogs/AddAccessReviewSourceDialog";
|
||||||
|
|
||||||
export const accessReviewSourcesTabQuery = graphql`
|
export const accessReviewSourcesTabQuery = graphql`
|
||||||
query AccessReviewSourcesTabQuery($organizationId: ID!) {
|
query AccessReviewSourcesTabQuery($organizationId: ID!) {
|
||||||
accessReviewDrivers {
|
accessReviewDrivers {
|
||||||
...AddAccessSourceDialogConnectorProviderInfoFragment
|
...AddAccessReviewSourceDialogConnectorProviderInfoFragment
|
||||||
}
|
}
|
||||||
organization: node(id: $organizationId) {
|
organization: node(id: $organizationId) {
|
||||||
__typename
|
__typename
|
||||||
... on Organization {
|
... on Organization {
|
||||||
canCreateSource: permission(action: "core:access-source:create")
|
canCreateSource: permission(action: "access-review:source:create")
|
||||||
...AccessReviewSourcesTabFragment
|
...AccessReviewSourcesTabFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,20 +62,20 @@ const sourcesFragment = graphql`
|
|||||||
@argumentDefinitions(
|
@argumentDefinitions(
|
||||||
first: { type: "Int", defaultValue: 50 }
|
first: { type: "Int", defaultValue: 50 }
|
||||||
order: {
|
order: {
|
||||||
type: "AccessSourceOrder"
|
type: "AccessReviewSourceOrder"
|
||||||
defaultValue: { direction: DESC, field: CREATED_AT }
|
defaultValue: { direction: DESC, field: CREATED_AT }
|
||||||
}
|
}
|
||||||
after: { type: "CursorKey", defaultValue: null }
|
after: { type: "CursorKey", defaultValue: null }
|
||||||
before: { type: "CursorKey", defaultValue: null }
|
before: { type: "CursorKey", defaultValue: null }
|
||||||
last: { type: "Int", defaultValue: null }
|
last: { type: "Int", defaultValue: null }
|
||||||
) {
|
) {
|
||||||
accessSources(
|
accessReviewSources(
|
||||||
first: $first
|
first: $first
|
||||||
after: $after
|
after: $after
|
||||||
last: $last
|
last: $last
|
||||||
before: $before
|
before: $before
|
||||||
orderBy: $order
|
orderBy: $order
|
||||||
) @connection(key: "AccessReviewSourcesTab_accessSources") {
|
) @connection(key: "AccessReviewSourcesTab_accessReviewSources") {
|
||||||
__id
|
__id
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
@@ -84,7 +84,7 @@ const sourcesFragment = graphql`
|
|||||||
connector {
|
connector {
|
||||||
provider
|
provider
|
||||||
}
|
}
|
||||||
...AccessSourceRowFragment
|
...AccessReviewSourceRowFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,13 +107,13 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
|
|||||||
throw new Error("Organization not found");
|
throw new Error("Organization not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectorProviderInfos = useFragment<AddAccessSourceDialogConnectorProviderInfoFragment$key>(
|
const connectorProviderInfos = useFragment<AddAccessReviewSourceDialogConnectorProviderInfoFragment$key>(
|
||||||
addAccessSourceDialogConnectorProviderInfoFragment,
|
addAccessReviewSourceDialogConnectorProviderInfoFragment,
|
||||||
accessReviewDrivers,
|
accessReviewDrivers,
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: { accessSources },
|
data: { accessReviewSources },
|
||||||
loadNext,
|
loadNext,
|
||||||
hasNext,
|
hasNext,
|
||||||
isLoadingNext,
|
isLoadingNext,
|
||||||
@@ -124,15 +124,15 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
|
|||||||
|
|
||||||
const existingSourceProviders = useMemo(
|
const existingSourceProviders = useMemo(
|
||||||
() =>
|
() =>
|
||||||
accessSources.edges
|
accessReviewSources.edges
|
||||||
.map(edge => edge.node.connector?.provider)
|
.map(edge => edge.node.connector?.provider)
|
||||||
.filter((p): p is NonNullable<typeof p> => p != null),
|
.filter((p): p is NonNullable<typeof p> => p != null),
|
||||||
[accessSources.edges],
|
[accessReviewSources.edges],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [createAccessSource, isCreatingSource]
|
const [createAccessReviewSource, isCreatingSource]
|
||||||
= useMutation<accessSourceMutationsCreateMutation>(
|
= useMutation<accessReviewSourceMutationsCreateMutation>(
|
||||||
createAccessSourceMutation,
|
createAccessReviewSourceMutation,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle OAuth callback: after the provider redirects back with connector_id,
|
// 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 callbackConnectorId = searchParams.get("connector_id");
|
||||||
const callbackProvider = searchParams.get("provider");
|
const callbackProvider = searchParams.get("provider");
|
||||||
const hasSourceForCallback = !!callbackConnectorId
|
const hasSourceForCallback = !!callbackConnectorId
|
||||||
&& accessSources?.edges.some(edge => edge.node.connectorId === callbackConnectorId);
|
&& accessReviewSources?.edges.some(edge => edge.node.connectorId === callbackConnectorId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!callbackConnectorId) return;
|
if (!callbackConnectorId) return;
|
||||||
@@ -164,7 +164,7 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
|
|||||||
: null;
|
: null;
|
||||||
const sourceName = providerInfo?.displayName ?? callbackProvider ?? "Source";
|
const sourceName = providerInfo?.displayName ?? callbackProvider ?? "Source";
|
||||||
|
|
||||||
createAccessSource({
|
createAccessReviewSource({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -172,7 +172,7 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
|
|||||||
name: sourceName,
|
name: sourceName,
|
||||||
csvData: null,
|
csvData: null,
|
||||||
},
|
},
|
||||||
connections: [accessSources.__id],
|
connections: [accessReviewSources.__id],
|
||||||
},
|
},
|
||||||
onCompleted(_, errors) {
|
onCompleted(_, errors) {
|
||||||
if (errors?.length) {
|
if (errors?.length) {
|
||||||
@@ -225,11 +225,11 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
|
|||||||
callbackConnectorId,
|
callbackConnectorId,
|
||||||
callbackProvider,
|
callbackProvider,
|
||||||
connectorProviderInfos,
|
connectorProviderInfos,
|
||||||
createAccessSource,
|
createAccessReviewSource,
|
||||||
hasSourceForCallback,
|
hasSourceForCallback,
|
||||||
isCreatingSource,
|
isCreatingSource,
|
||||||
organizationId,
|
organizationId,
|
||||||
accessSources.__id,
|
accessReviewSources.__id,
|
||||||
setSearchParams,
|
setSearchParams,
|
||||||
toast,
|
toast,
|
||||||
]);
|
]);
|
||||||
@@ -238,20 +238,20 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
{organization.canCreateSource && (
|
{organization.canCreateSource && (
|
||||||
<AddAccessSourceDialog
|
<AddAccessReviewSourceDialog
|
||||||
organizationId={organizationId}
|
organizationId={organizationId}
|
||||||
connectionId={accessSources.__id}
|
connectionId={accessReviewSources.__id}
|
||||||
providerInfos={connectorProviderInfos}
|
providerInfos={connectorProviderInfos}
|
||||||
existingSourceProviders={existingSourceProviders}
|
existingSourceProviders={existingSourceProviders}
|
||||||
>
|
>
|
||||||
<Button icon={IconPlusLarge}>
|
<Button icon={IconPlusLarge}>
|
||||||
{__("Add source")}
|
{__("Add source")}
|
||||||
</Button>
|
</Button>
|
||||||
</AddAccessSourceDialog>
|
</AddAccessReviewSourceDialog>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{accessSources && accessSources.edges.length > 0
|
{accessReviewSources && accessReviewSources.edges.length > 0
|
||||||
? (
|
? (
|
||||||
<Card>
|
<Card>
|
||||||
<Table>
|
<Table>
|
||||||
@@ -266,11 +266,11 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
|
|||||||
</Tr>
|
</Tr>
|
||||||
</Thead>
|
</Thead>
|
||||||
<Tbody>
|
<Tbody>
|
||||||
{accessSources.edges.map(edge => (
|
{accessReviewSources.edges.map(edge => (
|
||||||
<AccessSourceRow
|
<AccessReviewSourceRow
|
||||||
key={edge.node.id}
|
key={edge.node.id}
|
||||||
fKey={edge.node}
|
fKey={edge.node}
|
||||||
connectionId={accessSources.__id}
|
connectionId={accessReviewSources.__id}
|
||||||
organizationId={organizationId}
|
organizationId={organizationId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const accessReviewRoutes = [
|
|||||||
path: "access-reviews/sources/new/csv",
|
path: "access-reviews/sources/new/csv",
|
||||||
Fallback: PageSkeleton,
|
Fallback: PageSkeleton,
|
||||||
Component: lazy(
|
Component: lazy(
|
||||||
() => import("#/pages/organizations/access-reviews/CreateCsvAccessSourcePageLoader"),
|
() => import("#/pages/organizations/access-reviews/CreateCsvAccessReviewSourcePageLoader"),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
] satisfies AppRoute[];
|
] satisfies AppRoute[];
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ and `r.AuthorizeBatch` (MCP) — keep the returned scope and pass it down.
|
|||||||
| Product action constants (`core:*`) | `pkg/probo/actions.go` |
|
| Product action constants (`core:*`) | `pkg/probo/actions.go` |
|
||||||
| IAM action constants (`iam:*`) | `pkg/iam/iam_actions.go` |
|
| IAM action constants (`iam:*`) | `pkg/iam/iam_actions.go` |
|
||||||
| Product role policies (`ProboPolicySet`) | `pkg/probo/policies.go` |
|
| Product role policies (`ProboPolicySet`) | `pkg/probo/policies.go` |
|
||||||
|
| Per-service policy sets (e.g. `accessreview.PolicySet`, `agentrun.PolicySet`) | `pkg/<service>/actions.go`, `pkg/<service>/policies.go` |
|
||||||
| IAM role policies (`IAMPolicySet`) | `pkg/iam/iam_policies.go` |
|
| IAM role policies (`IAMPolicySet`) | `pkg/iam/iam_policies.go` |
|
||||||
| Authorizer + `AuthorizationAttributer` | `pkg/iam/authorizer.go` |
|
| Authorizer + `AuthorizationAttributer` | `pkg/iam/authorizer.go` |
|
||||||
| PolicySet registration | `pkg/iam/policy_set.go` |
|
| PolicySet registration | `pkg/iam/policy_set.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"
|
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()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
orgID := owner.GetOrganizationID().String()
|
orgID := owner.GetOrganizationID().String()
|
||||||
@@ -35,9 +35,9 @@ func TestAccessSource_Create(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
mutation($input: CreateAccessSourceInput!) {
|
mutation($input: CreateAccessReviewSourceInput!) {
|
||||||
createAccessSource(input: $input) {
|
createAccessReviewSource(input: $input) {
|
||||||
accessSourceEdge {
|
accessReviewSourceEdge {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
@@ -50,16 +50,16 @@ func TestAccessSource_Create(t *testing.T) {
|
|||||||
`
|
`
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
CreateAccessSource struct {
|
CreateAccessReviewSource struct {
|
||||||
AccessSourceEdge struct {
|
AccessReviewSourceEdge struct {
|
||||||
Node struct {
|
Node struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
} `json:"node"`
|
} `json:"node"`
|
||||||
} `json:"accessSourceEdge"`
|
} `json:"accessReviewSourceEdge"`
|
||||||
} `json:"createAccessSource"`
|
} `json:"createAccessReviewSource"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := owner.Execute(query, map[string]any{
|
err := owner.Execute(query, map[string]any{
|
||||||
@@ -70,7 +70,7 @@ func TestAccessSource_Create(t *testing.T) {
|
|||||||
}, &result)
|
}, &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
node := result.CreateAccessSource.AccessSourceEdge.Node
|
node := result.CreateAccessReviewSource.AccessReviewSourceEdge.Node
|
||||||
assert.NotEmpty(t, node.ID)
|
assert.NotEmpty(t, node.ID)
|
||||||
assert.Equal(t, "Slack", node.Name)
|
assert.Equal(t, "Slack", node.Name)
|
||||||
assert.NotEmpty(t, node.CreatedAt)
|
assert.NotEmpty(t, node.CreatedAt)
|
||||||
@@ -80,9 +80,9 @@ func TestAccessSource_Create(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
mutation($input: CreateAccessSourceInput!) {
|
mutation($input: CreateAccessReviewSourceInput!) {
|
||||||
createAccessSource(input: $input) {
|
createAccessReviewSource(input: $input) {
|
||||||
accessSourceEdge {
|
accessReviewSourceEdge {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
@@ -94,15 +94,15 @@ func TestAccessSource_Create(t *testing.T) {
|
|||||||
`
|
`
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
CreateAccessSource struct {
|
CreateAccessReviewSource struct {
|
||||||
AccessSourceEdge struct {
|
AccessReviewSourceEdge struct {
|
||||||
Node struct {
|
Node struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CsvData *string `json:"csvData"`
|
CsvData *string `json:"csvData"`
|
||||||
} `json:"node"`
|
} `json:"node"`
|
||||||
} `json:"accessSourceEdge"`
|
} `json:"accessReviewSourceEdge"`
|
||||||
} `json:"createAccessSource"`
|
} `json:"createAccessReviewSource"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := owner.Execute(query, map[string]any{
|
err := owner.Execute(query, map[string]any{
|
||||||
@@ -114,7 +114,7 @@ func TestAccessSource_Create(t *testing.T) {
|
|||||||
}, &result)
|
}, &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
node := result.CreateAccessSource.AccessSourceEdge.Node
|
node := result.CreateAccessReviewSource.AccessReviewSourceEdge.Node
|
||||||
assert.NotEmpty(t, node.ID)
|
assert.NotEmpty(t, node.ID)
|
||||||
assert.Equal(t, "CSV Import", node.Name)
|
assert.Equal(t, "CSV Import", node.Name)
|
||||||
require.NotNil(t, node.CsvData)
|
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()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
orgID := owner.GetOrganizationID().String()
|
orgID := owner.GetOrganizationID().String()
|
||||||
sourceID := factory.NewAccessSource(owner, orgID).
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
||||||
WithName("Original Source").
|
WithName("Original Source").
|
||||||
Create()
|
Create()
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
mutation($input: UpdateAccessSourceInput!) {
|
mutation($input: UpdateAccessReviewSourceInput!) {
|
||||||
updateAccessSource(input: $input) {
|
updateAccessReviewSource(input: $input) {
|
||||||
accessSource {
|
accessReviewSource {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
}
|
}
|
||||||
@@ -142,71 +142,71 @@ func TestAccessSource_Update(t *testing.T) {
|
|||||||
`
|
`
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
UpdateAccessSource struct {
|
UpdateAccessReviewSource struct {
|
||||||
AccessSource struct {
|
AccessReviewSource struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"accessSource"`
|
} `json:"accessReviewSource"`
|
||||||
} `json:"updateAccessSource"`
|
} `json:"updateAccessReviewSource"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := owner.Execute(query, map[string]any{
|
err := owner.Execute(query, map[string]any{
|
||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
"accessSourceId": sourceID,
|
"accessReviewSourceId": sourceID,
|
||||||
"name": "Updated Source",
|
"name": "Updated Source",
|
||||||
},
|
},
|
||||||
}, &result)
|
}, &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, sourceID, result.UpdateAccessSource.AccessSource.ID)
|
assert.Equal(t, sourceID, result.UpdateAccessReviewSource.AccessReviewSource.ID)
|
||||||
assert.Equal(t, "Updated Source", result.UpdateAccessSource.AccessSource.Name)
|
assert.Equal(t, "Updated Source", result.UpdateAccessReviewSource.AccessReviewSource.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAccessSource_Delete(t *testing.T) {
|
func TestAccessReviewSource_Delete(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
orgID := owner.GetOrganizationID().String()
|
orgID := owner.GetOrganizationID().String()
|
||||||
sourceID := factory.NewAccessSource(owner, orgID).
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
||||||
WithName("Source to Delete").
|
WithName("Source to Delete").
|
||||||
Create()
|
Create()
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
mutation($input: DeleteAccessSourceInput!) {
|
mutation($input: DeleteAccessReviewSourceInput!) {
|
||||||
deleteAccessSource(input: $input) {
|
deleteAccessReviewSource(input: $input) {
|
||||||
deletedAccessSourceId
|
deletedAccessReviewSourceId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
DeleteAccessSource struct {
|
DeleteAccessReviewSource struct {
|
||||||
DeletedAccessSourceID string `json:"deletedAccessSourceId"`
|
DeletedAccessReviewSourceID string `json:"deletedAccessReviewSourceId"`
|
||||||
} `json:"deleteAccessSource"`
|
} `json:"deleteAccessReviewSource"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := owner.Execute(query, map[string]any{
|
err := owner.Execute(query, map[string]any{
|
||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
"accessSourceId": sourceID,
|
"accessReviewSourceId": sourceID,
|
||||||
},
|
},
|
||||||
}, &result)
|
}, &result)
|
||||||
require.NoError(t, err)
|
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()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
orgID := owner.GetOrganizationID().String()
|
orgID := owner.GetOrganizationID().String()
|
||||||
|
|
||||||
for _, name := range []string{"Slack", "GitHub", "Google Workspace"} {
|
for _, name := range []string{"Slack", "GitHub", "Google Workspace"} {
|
||||||
factory.NewAccessSource(owner, orgID).WithName(name).Create()
|
factory.NewAccessReviewSource(owner, orgID).WithName(name).Create()
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
query($id: ID!) {
|
query($id: ID!) {
|
||||||
node(id: $id) {
|
node(id: $id) {
|
||||||
... on Organization {
|
... on Organization {
|
||||||
accessSources(first: 10) {
|
accessReviewSources(first: 10) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
@@ -222,7 +222,7 @@ func TestAccessSource_List(t *testing.T) {
|
|||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
Node struct {
|
Node struct {
|
||||||
AccessSources struct {
|
AccessReviewSources struct {
|
||||||
Edges []struct {
|
Edges []struct {
|
||||||
Node struct {
|
Node struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -230,13 +230,13 @@ func TestAccessSource_List(t *testing.T) {
|
|||||||
} `json:"node"`
|
} `json:"node"`
|
||||||
} `json:"edges"`
|
} `json:"edges"`
|
||||||
TotalCount int `json:"totalCount"`
|
TotalCount int `json:"totalCount"`
|
||||||
} `json:"accessSources"`
|
} `json:"accessReviewSources"`
|
||||||
} `json:"node"`
|
} `json:"node"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := owner.Execute(query, map[string]any{"id": orgID}, &result)
|
err := owner.Execute(query, map[string]any{"id": orgID}, &result)
|
||||||
require.NoError(t, err)
|
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) {
|
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.Run("with access sources", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
source1ID := factory.NewAccessSource(owner, orgID).
|
source1ID := factory.NewAccessReviewSource(owner, orgID).
|
||||||
WithName("Slack Source").
|
WithName("Slack Source").
|
||||||
Create()
|
Create()
|
||||||
source2ID := factory.NewAccessSource(owner, orgID).
|
source2ID := factory.NewAccessReviewSource(owner, orgID).
|
||||||
WithName("GitHub Source").
|
WithName("GitHub Source").
|
||||||
Create()
|
Create()
|
||||||
|
|
||||||
@@ -309,7 +309,7 @@ func TestAccessReviewCampaign_Create(t *testing.T) {
|
|||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
scopeSources {
|
sources {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
}
|
}
|
||||||
@@ -323,12 +323,12 @@ func TestAccessReviewCampaign_Create(t *testing.T) {
|
|||||||
CreateAccessReviewCampaign struct {
|
CreateAccessReviewCampaign struct {
|
||||||
AccessReviewCampaignEdge struct {
|
AccessReviewCampaignEdge struct {
|
||||||
Node struct {
|
Node struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ScopeSources []struct {
|
CampaignSources []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"scopeSources"`
|
} `json:"sources"`
|
||||||
} `json:"node"`
|
} `json:"node"`
|
||||||
} `json:"accessReviewCampaignEdge"`
|
} `json:"accessReviewCampaignEdge"`
|
||||||
} `json:"createAccessReviewCampaign"`
|
} `json:"createAccessReviewCampaign"`
|
||||||
@@ -336,9 +336,9 @@ func TestAccessReviewCampaign_Create(t *testing.T) {
|
|||||||
|
|
||||||
err := owner.Execute(query, map[string]any{
|
err := owner.Execute(query, map[string]any{
|
||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
"organizationId": orgID,
|
"organizationId": orgID,
|
||||||
"name": "Campaign with Sources",
|
"name": "Campaign with Sources",
|
||||||
"accessSourceIds": []string{source1ID, source2ID},
|
"accessReviewSourceIds": []string{source1ID, source2ID},
|
||||||
},
|
},
|
||||||
}, &result)
|
}, &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -346,7 +346,7 @@ func TestAccessReviewCampaign_Create(t *testing.T) {
|
|||||||
node := result.CreateAccessReviewCampaign.AccessReviewCampaignEdge.Node
|
node := result.CreateAccessReviewCampaign.AccessReviewCampaignEdge.Node
|
||||||
assert.NotEmpty(t, node.ID)
|
assert.NotEmpty(t, node.ID)
|
||||||
assert.Equal(t, "Campaign with Sources", node.Name)
|
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) {
|
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)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
orgID := owner.GetOrganizationID().String()
|
orgID := owner.GetOrganizationID().String()
|
||||||
|
|
||||||
sourceID := factory.NewAccessSource(owner, orgID).
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
||||||
WithName("Source for Delete").
|
WithName("Source for Delete").
|
||||||
WithCsvData(testCsvData).
|
WithCsvData(testCsvData).
|
||||||
Create()
|
Create()
|
||||||
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
||||||
WithName("Campaign to Cascade Delete").
|
WithName("Campaign to Cascade Delete").
|
||||||
WithAccessSourceIDs([]string{sourceID}).
|
WithAccessReviewSourceIDs([]string{sourceID}).
|
||||||
Create()
|
Create()
|
||||||
|
|
||||||
const deleteMutation = `
|
const deleteMutation = `
|
||||||
@@ -676,14 +676,14 @@ func TestAccessReviewCampaign_StartWithCsvSource(t *testing.T) {
|
|||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
orgID := owner.GetOrganizationID().String()
|
orgID := owner.GetOrganizationID().String()
|
||||||
|
|
||||||
sourceID := factory.NewAccessSource(owner, orgID).
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
||||||
WithName("CSV Test Source").
|
WithName("CSV Test Source").
|
||||||
WithCsvData(testCsvData).
|
WithCsvData(testCsvData).
|
||||||
Create()
|
Create()
|
||||||
|
|
||||||
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
||||||
WithName("CSV Campaign").
|
WithName("CSV Campaign").
|
||||||
WithAccessSourceIDs([]string{sourceID}).
|
WithAccessReviewSourceIDs([]string{sourceID}).
|
||||||
Create()
|
Create()
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
@@ -721,12 +721,12 @@ func TestAccessReviewCampaign_StartWithCsvSource(t *testing.T) {
|
|||||||
assert.NotNil(t, campaign.StartedAt)
|
assert.NotNil(t, campaign.StartedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAccessReviewCampaign_AddAndRemoveScopeSource(t *testing.T) {
|
func TestAccessReviewCampaign_AddAndRemoveCampaignSource(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
orgID := owner.GetOrganizationID().String()
|
orgID := owner.GetOrganizationID().String()
|
||||||
|
|
||||||
sourceID := factory.NewAccessSource(owner, orgID).
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
||||||
WithName("Scope Source").
|
WithName("Scope Source").
|
||||||
Create()
|
Create()
|
||||||
|
|
||||||
@@ -736,13 +736,16 @@ func TestAccessReviewCampaign_AddAndRemoveScopeSource(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("add scope source", func(t *testing.T) {
|
t.Run("add scope source", func(t *testing.T) {
|
||||||
const query = `
|
const query = `
|
||||||
mutation($input: AddAccessReviewCampaignScopeSourceInput!) {
|
mutation($input: AddAccessReviewCampaignSourceInput!) {
|
||||||
addAccessReviewCampaignScopeSource(input: $input) {
|
addAccessReviewCampaignSource(input: $input) {
|
||||||
accessReviewCampaign {
|
accessReviewCampaign {
|
||||||
id
|
id
|
||||||
scopeSources {
|
sources {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
source {
|
||||||
|
id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -750,38 +753,45 @@ func TestAccessReviewCampaign_AddAndRemoveScopeSource(t *testing.T) {
|
|||||||
`
|
`
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
AddAccessReviewCampaignScopeSource struct {
|
AddAccessReviewCampaignSource struct {
|
||||||
AccessReviewCampaign struct {
|
AccessReviewCampaign struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ScopeSources []struct {
|
CampaignSources []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"scopeSources"`
|
Source *struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"source"`
|
||||||
|
} `json:"sources"`
|
||||||
} `json:"accessReviewCampaign"`
|
} `json:"accessReviewCampaign"`
|
||||||
} `json:"addAccessReviewCampaignScopeSource"`
|
} `json:"addAccessReviewCampaignSource"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := owner.Execute(query, map[string]any{
|
err := owner.Execute(query, map[string]any{
|
||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
"accessReviewCampaignId": campaignID,
|
"accessReviewCampaignId": campaignID,
|
||||||
"accessSourceId": sourceID,
|
"accessReviewSourceId": sourceID,
|
||||||
},
|
},
|
||||||
}, &result)
|
}, &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
campaign := result.AddAccessReviewCampaignScopeSource.AccessReviewCampaign
|
campaign := result.AddAccessReviewCampaignSource.AccessReviewCampaign
|
||||||
assert.Equal(t, campaignID, campaign.ID)
|
assert.Equal(t, campaignID, campaign.ID)
|
||||||
assert.Len(t, campaign.ScopeSources, 1)
|
assert.Len(t, campaign.CampaignSources, 1)
|
||||||
assert.Equal(t, sourceID, campaign.ScopeSources[0].ID)
|
// 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) {
|
t.Run("remove scope source", func(t *testing.T) {
|
||||||
const query = `
|
const query = `
|
||||||
mutation($input: RemoveAccessReviewCampaignScopeSourceInput!) {
|
mutation($input: RemoveAccessReviewCampaignSourceInput!) {
|
||||||
removeAccessReviewCampaignScopeSource(input: $input) {
|
removeAccessReviewCampaignSource(input: $input) {
|
||||||
accessReviewCampaign {
|
accessReviewCampaign {
|
||||||
id
|
id
|
||||||
scopeSources {
|
sources {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -790,27 +800,27 @@ func TestAccessReviewCampaign_AddAndRemoveScopeSource(t *testing.T) {
|
|||||||
`
|
`
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
RemoveAccessReviewCampaignScopeSource struct {
|
RemoveAccessReviewCampaignSource struct {
|
||||||
AccessReviewCampaign struct {
|
AccessReviewCampaign struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ScopeSources []struct {
|
CampaignSources []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
} `json:"scopeSources"`
|
} `json:"sources"`
|
||||||
} `json:"accessReviewCampaign"`
|
} `json:"accessReviewCampaign"`
|
||||||
} `json:"removeAccessReviewCampaignScopeSource"`
|
} `json:"removeAccessReviewCampaignSource"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := owner.Execute(query, map[string]any{
|
err := owner.Execute(query, map[string]any{
|
||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
"accessReviewCampaignId": campaignID,
|
"accessReviewCampaignId": campaignID,
|
||||||
"accessSourceId": sourceID,
|
"accessReviewSourceId": sourceID,
|
||||||
},
|
},
|
||||||
}, &result)
|
}, &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
campaign := result.RemoveAccessReviewCampaignScopeSource.AccessReviewCampaign
|
campaign := result.RemoveAccessReviewCampaignSource.AccessReviewCampaign
|
||||||
assert.Equal(t, campaignID, campaign.ID)
|
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)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
orgID := owner.GetOrganizationID().String()
|
orgID := owner.GetOrganizationID().String()
|
||||||
|
|
||||||
sourceID := factory.NewAccessSource(owner, orgID).
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
||||||
WithName("Cancel Test Source").
|
WithName("Cancel Test Source").
|
||||||
WithCsvData(testCsvData).
|
WithCsvData(testCsvData).
|
||||||
Create()
|
Create()
|
||||||
|
|
||||||
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
||||||
WithName("Campaign to Cancel").
|
WithName("Campaign to Cancel").
|
||||||
WithAccessSourceIDs([]string{sourceID}).
|
WithAccessReviewSourceIDs([]string{sourceID}).
|
||||||
Create()
|
Create()
|
||||||
|
|
||||||
// Start the campaign first
|
// Start the campaign first
|
||||||
@@ -971,7 +981,7 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) {
|
|||||||
orgID := owner.GetOrganizationID().String()
|
orgID := owner.GetOrganizationID().String()
|
||||||
|
|
||||||
// Step 1: Create a CSV source with test data
|
// Step 1: Create a CSV source with test data
|
||||||
sourceID := factory.NewAccessSource(owner, orgID).
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
||||||
WithName("Lifecycle Test Source").
|
WithName("Lifecycle Test Source").
|
||||||
WithCsvData(testCsvData).
|
WithCsvData(testCsvData).
|
||||||
Create()
|
Create()
|
||||||
@@ -986,7 +996,7 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) {
|
|||||||
name
|
name
|
||||||
description
|
description
|
||||||
status
|
status
|
||||||
scopeSources {
|
sources {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -999,13 +1009,13 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) {
|
|||||||
CreateAccessReviewCampaign struct {
|
CreateAccessReviewCampaign struct {
|
||||||
AccessReviewCampaignEdge struct {
|
AccessReviewCampaignEdge struct {
|
||||||
Node struct {
|
Node struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ScopeSources []struct {
|
CampaignSources []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
} `json:"scopeSources"`
|
} `json:"sources"`
|
||||||
} `json:"node"`
|
} `json:"node"`
|
||||||
} `json:"accessReviewCampaignEdge"`
|
} `json:"accessReviewCampaignEdge"`
|
||||||
} `json:"createAccessReviewCampaign"`
|
} `json:"createAccessReviewCampaign"`
|
||||||
@@ -1013,10 +1023,10 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) {
|
|||||||
|
|
||||||
err := owner.Execute(createQuery, map[string]any{
|
err := owner.Execute(createQuery, map[string]any{
|
||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
"organizationId": orgID,
|
"organizationId": orgID,
|
||||||
"name": "Full Lifecycle Campaign",
|
"name": "Full Lifecycle Campaign",
|
||||||
"description": "Testing the full lifecycle",
|
"description": "Testing the full lifecycle",
|
||||||
"accessSourceIds": []string{sourceID},
|
"accessReviewSourceIds": []string{sourceID},
|
||||||
},
|
},
|
||||||
}, &createResult)
|
}, &createResult)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -1025,7 +1035,7 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) {
|
|||||||
campaignID := campaignNode.ID
|
campaignID := campaignNode.ID
|
||||||
assert.Equal(t, "DRAFT", campaignNode.Status)
|
assert.Equal(t, "DRAFT", campaignNode.Status)
|
||||||
assert.Equal(t, "Testing the full lifecycle", campaignNode.Description)
|
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)
|
// Step 3: Start the campaign (triggers worker to fetch CSV data)
|
||||||
const startQuery = `
|
const startQuery = `
|
||||||
@@ -1144,8 +1154,8 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) {
|
|||||||
|
|
||||||
// Step 5: Record decisions on all entries
|
// Step 5: Record decisions on all entries
|
||||||
const recordDecisionQuery = `
|
const recordDecisionQuery = `
|
||||||
mutation($input: RecordAccessEntryDecisionInput!) {
|
mutation($input: RecordAccessReviewEntryDecisionInput!) {
|
||||||
recordAccessEntryDecision(input: $input) {
|
recordAccessReviewEntryDecision(input: $input) {
|
||||||
accessEntry {
|
accessEntry {
|
||||||
id
|
id
|
||||||
decision
|
decision
|
||||||
@@ -1162,8 +1172,8 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) {
|
|||||||
|
|
||||||
for _, edge := range campaignResult.Node.Entries.Edges {
|
for _, edge := range campaignResult.Node.Entries.Edges {
|
||||||
var decisionResult struct {
|
var decisionResult struct {
|
||||||
RecordAccessEntryDecision struct {
|
RecordAccessReviewEntryDecision struct {
|
||||||
AccessEntry struct {
|
AccessReviewEntry struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Decision string `json:"decision"`
|
Decision string `json:"decision"`
|
||||||
DecidedAt *string `json:"decidedAt"`
|
DecidedAt *string `json:"decidedAt"`
|
||||||
@@ -1172,18 +1182,18 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) {
|
|||||||
Decision string `json:"decision"`
|
Decision string `json:"decision"`
|
||||||
} `json:"decisionHistory"`
|
} `json:"decisionHistory"`
|
||||||
} `json:"accessEntry"`
|
} `json:"accessEntry"`
|
||||||
} `json:"recordAccessEntryDecision"`
|
} `json:"recordAccessReviewEntryDecision"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err = owner.Execute(recordDecisionQuery, map[string]any{
|
err = owner.Execute(recordDecisionQuery, map[string]any{
|
||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
"accessEntryId": edge.Node.ID,
|
"accessReviewEntryId": edge.Node.ID,
|
||||||
"decision": "APPROVED",
|
"decision": "APPROVED",
|
||||||
},
|
},
|
||||||
}, &decisionResult)
|
}, &decisionResult)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
entry := decisionResult.RecordAccessEntryDecision.AccessEntry
|
entry := decisionResult.RecordAccessReviewEntryDecision.AccessReviewEntry
|
||||||
assert.Equal(t, "APPROVED", entry.Decision)
|
assert.Equal(t, "APPROVED", entry.Decision)
|
||||||
assert.NotNil(t, entry.DecidedAt)
|
assert.NotNil(t, entry.DecidedAt)
|
||||||
|
|
||||||
@@ -1232,14 +1242,14 @@ func TestAccessReviewCampaign_CloseRequiresAllDecisions(t *testing.T) {
|
|||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
orgID := owner.GetOrganizationID().String()
|
orgID := owner.GetOrganizationID().String()
|
||||||
|
|
||||||
sourceID := factory.NewAccessSource(owner, orgID).
|
sourceID := factory.NewAccessReviewSource(owner, orgID).
|
||||||
WithName("Close Guard Source").
|
WithName("Close Guard Source").
|
||||||
WithCsvData(testCsvData).
|
WithCsvData(testCsvData).
|
||||||
Create()
|
Create()
|
||||||
|
|
||||||
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
|
||||||
WithName("Close Guard Campaign").
|
WithName("Close Guard Campaign").
|
||||||
WithAccessSourceIDs([]string{sourceID}).
|
WithAccessReviewSourceIDs([]string{sourceID}).
|
||||||
Create()
|
Create()
|
||||||
|
|
||||||
// Start the campaign
|
// Start the campaign
|
||||||
@@ -1334,9 +1344,9 @@ func TestAccessReview_TenantIsolation(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
mutation($input: CreateAccessSourceInput!) {
|
mutation($input: CreateAccessReviewSourceInput!) {
|
||||||
createAccessSource(input: $input) {
|
createAccessReviewSource(input: $input) {
|
||||||
accessSourceEdge {
|
accessReviewSourceEdge {
|
||||||
node { id }
|
node { id }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,32 +204,32 @@ const (
|
|||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
createAccessSourceMutation = `
|
createAccessReviewSourceMutation = `
|
||||||
mutation CreateAccessSource($input: CreateAccessSourceInput!) {
|
mutation CreateAccessReviewSource($input: CreateAccessReviewSourceInput!) {
|
||||||
createAccessSource(input: $input) {
|
createAccessReviewSource(input: $input) {
|
||||||
accessSourceEdge { node { id } }
|
accessReviewSourceEdge { node { id } }
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
updateAccessSourceMutation = `
|
updateAccessReviewSourceMutation = `
|
||||||
mutation UpdateAccessSource($input: UpdateAccessSourceInput!) {
|
mutation UpdateAccessReviewSource($input: UpdateAccessReviewSourceInput!) {
|
||||||
updateAccessSource(input: $input) {
|
updateAccessReviewSource(input: $input) {
|
||||||
accessSource { id }
|
accessReviewSource { id }
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
deleteAccessSourceMutation = `
|
deleteAccessReviewSourceMutation = `
|
||||||
mutation DeleteAccessSource($input: DeleteAccessSourceInput!) {
|
mutation DeleteAccessReviewSource($input: DeleteAccessReviewSourceInput!) {
|
||||||
deleteAccessSource(input: $input) {
|
deleteAccessReviewSource(input: $input) {
|
||||||
deletedAccessSourceId
|
deletedAccessReviewSourceId
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
listAccessSourcesQuery = `
|
listAccessReviewSourcesQuery = `
|
||||||
query GetAccessSources($id: ID!) {
|
query GetAccessReviewSources($id: ID!) {
|
||||||
node(id: $id) {
|
node(id: $id) {
|
||||||
... on Organization {
|
... 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()
|
taskID := factory.NewTask(owner, measureID).WithName("RBAC Test Task").Create()
|
||||||
riskID := factory.NewRisk(owner).WithName("RBAC Test Risk").Create()
|
riskID := factory.NewRisk(owner).WithName("RBAC Test Risk").Create()
|
||||||
thirdPartyID := factory.NewThirdParty(owner).WithName("RBAC Test ThirdParty").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()
|
accessReviewCampaignID := factory.NewAccessReviewCampaign(owner, owner.GetOrganizationID().String()).WithName("RBAC Test Campaign").Create()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -1063,9 +1063,9 @@ func TestRBAC(t *testing.T) {
|
|||||||
name: "owner can create access source",
|
name: "owner can create access source",
|
||||||
role: "owner",
|
role: "owner",
|
||||||
client: owner,
|
client: owner,
|
||||||
query: createAccessSourceMutation,
|
query: createAccessReviewSourceMutation,
|
||||||
variables: func() map[string]any {
|
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,
|
shouldAllow: true,
|
||||||
},
|
},
|
||||||
@@ -1073,9 +1073,9 @@ func TestRBAC(t *testing.T) {
|
|||||||
name: "admin can create access source",
|
name: "admin can create access source",
|
||||||
role: "admin",
|
role: "admin",
|
||||||
client: admin,
|
client: admin,
|
||||||
query: createAccessSourceMutation,
|
query: createAccessReviewSourceMutation,
|
||||||
variables: func() map[string]any {
|
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,
|
shouldAllow: true,
|
||||||
},
|
},
|
||||||
@@ -1083,9 +1083,9 @@ func TestRBAC(t *testing.T) {
|
|||||||
name: "viewer cannot create access source",
|
name: "viewer cannot create access source",
|
||||||
role: "viewer",
|
role: "viewer",
|
||||||
client: viewer,
|
client: viewer,
|
||||||
query: createAccessSourceMutation,
|
query: createAccessReviewSourceMutation,
|
||||||
variables: func() map[string]any {
|
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,
|
shouldAllow: false,
|
||||||
},
|
},
|
||||||
@@ -1094,9 +1094,9 @@ func TestRBAC(t *testing.T) {
|
|||||||
name: "owner can update access source",
|
name: "owner can update access source",
|
||||||
role: "owner",
|
role: "owner",
|
||||||
client: owner,
|
client: owner,
|
||||||
query: updateAccessSourceMutation,
|
query: updateAccessReviewSourceMutation,
|
||||||
variables: func() map[string]any {
|
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,
|
shouldAllow: true,
|
||||||
},
|
},
|
||||||
@@ -1104,9 +1104,9 @@ func TestRBAC(t *testing.T) {
|
|||||||
name: "admin can update access source",
|
name: "admin can update access source",
|
||||||
role: "admin",
|
role: "admin",
|
||||||
client: admin,
|
client: admin,
|
||||||
query: updateAccessSourceMutation,
|
query: updateAccessReviewSourceMutation,
|
||||||
variables: func() map[string]any {
|
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,
|
shouldAllow: true,
|
||||||
},
|
},
|
||||||
@@ -1114,9 +1114,9 @@ func TestRBAC(t *testing.T) {
|
|||||||
name: "viewer cannot update access source",
|
name: "viewer cannot update access source",
|
||||||
role: "viewer",
|
role: "viewer",
|
||||||
client: viewer,
|
client: viewer,
|
||||||
query: updateAccessSourceMutation,
|
query: updateAccessReviewSourceMutation,
|
||||||
variables: func() map[string]any {
|
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,
|
shouldAllow: false,
|
||||||
},
|
},
|
||||||
@@ -1125,10 +1125,10 @@ func TestRBAC(t *testing.T) {
|
|||||||
name: "owner can delete access source",
|
name: "owner can delete access source",
|
||||||
role: "owner",
|
role: "owner",
|
||||||
client: owner,
|
client: owner,
|
||||||
query: deleteAccessSourceMutation,
|
query: deleteAccessReviewSourceMutation,
|
||||||
variables: func() map[string]any {
|
variables: func() map[string]any {
|
||||||
id := factory.NewAccessSource(owner, owner.GetOrganizationID().String()).WithName(factory.SafeName("ToDelete")).Create()
|
id := factory.NewAccessReviewSource(owner, owner.GetOrganizationID().String()).WithName(factory.SafeName("ToDelete")).Create()
|
||||||
return map[string]any{"input": map[string]any{"accessSourceId": id}}
|
return map[string]any{"input": map[string]any{"accessReviewSourceId": id}}
|
||||||
},
|
},
|
||||||
shouldAllow: true,
|
shouldAllow: true,
|
||||||
},
|
},
|
||||||
@@ -1136,10 +1136,10 @@ func TestRBAC(t *testing.T) {
|
|||||||
name: "admin can delete access source",
|
name: "admin can delete access source",
|
||||||
role: "admin",
|
role: "admin",
|
||||||
client: admin,
|
client: admin,
|
||||||
query: deleteAccessSourceMutation,
|
query: deleteAccessReviewSourceMutation,
|
||||||
variables: func() map[string]any {
|
variables: func() map[string]any {
|
||||||
id := factory.NewAccessSource(owner, owner.GetOrganizationID().String()).WithName(factory.SafeName("ToDelete")).Create()
|
id := factory.NewAccessReviewSource(owner, owner.GetOrganizationID().String()).WithName(factory.SafeName("ToDelete")).Create()
|
||||||
return map[string]any{"input": map[string]any{"accessSourceId": id}}
|
return map[string]any{"input": map[string]any{"accessReviewSourceId": id}}
|
||||||
},
|
},
|
||||||
shouldAllow: true,
|
shouldAllow: true,
|
||||||
},
|
},
|
||||||
@@ -1147,10 +1147,10 @@ func TestRBAC(t *testing.T) {
|
|||||||
name: "viewer cannot delete access source",
|
name: "viewer cannot delete access source",
|
||||||
role: "viewer",
|
role: "viewer",
|
||||||
client: viewer,
|
client: viewer,
|
||||||
query: deleteAccessSourceMutation,
|
query: deleteAccessReviewSourceMutation,
|
||||||
variables: func() map[string]any {
|
variables: func() map[string]any {
|
||||||
id := factory.NewAccessSource(owner, owner.GetOrganizationID().String()).WithName(factory.SafeName("ToDelete")).Create()
|
id := factory.NewAccessReviewSource(owner, owner.GetOrganizationID().String()).WithName(factory.SafeName("ToDelete")).Create()
|
||||||
return map[string]any{"input": map[string]any{"accessSourceId": id}}
|
return map[string]any{"input": map[string]any{"accessReviewSourceId": id}}
|
||||||
},
|
},
|
||||||
shouldAllow: false,
|
shouldAllow: false,
|
||||||
},
|
},
|
||||||
@@ -1159,7 +1159,7 @@ func TestRBAC(t *testing.T) {
|
|||||||
name: "owner can list access sources",
|
name: "owner can list access sources",
|
||||||
role: "owner",
|
role: "owner",
|
||||||
client: owner,
|
client: owner,
|
||||||
query: listAccessSourcesQuery,
|
query: listAccessReviewSourcesQuery,
|
||||||
variables: func() map[string]any {
|
variables: func() map[string]any {
|
||||||
return map[string]any{"id": owner.GetOrganizationID().String()}
|
return map[string]any{"id": owner.GetOrganizationID().String()}
|
||||||
},
|
},
|
||||||
@@ -1169,7 +1169,7 @@ func TestRBAC(t *testing.T) {
|
|||||||
name: "admin can list access sources",
|
name: "admin can list access sources",
|
||||||
role: "admin",
|
role: "admin",
|
||||||
client: admin,
|
client: admin,
|
||||||
query: listAccessSourcesQuery,
|
query: listAccessReviewSourcesQuery,
|
||||||
variables: func() map[string]any {
|
variables: func() map[string]any {
|
||||||
return map[string]any{"id": owner.GetOrganizationID().String()}
|
return map[string]any{"id": owner.GetOrganizationID().String()}
|
||||||
},
|
},
|
||||||
@@ -1179,7 +1179,7 @@ func TestRBAC(t *testing.T) {
|
|||||||
name: "viewer can list access sources",
|
name: "viewer can list access sources",
|
||||||
role: "viewer",
|
role: "viewer",
|
||||||
client: viewer,
|
client: viewer,
|
||||||
query: listAccessSourcesQuery,
|
query: listAccessReviewSourcesQuery,
|
||||||
variables: func() map[string]any {
|
variables: func() map[string]any {
|
||||||
return map[string]any{"id": owner.GetOrganizationID().String()}
|
return map[string]any{"id": owner.GetOrganizationID().String()}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -972,7 +972,7 @@ func (b *ProcessingActivityBuilder) Create() string {
|
|||||||
return CreateProcessingActivity(b.client, b.attrs)
|
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()
|
c.T.Helper()
|
||||||
|
|
||||||
var a Attrs
|
var a Attrs
|
||||||
@@ -981,9 +981,9 @@ func CreateAccessSource(c *testutil.Client, organizationID string, attrs ...Attr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
mutation($input: CreateAccessSourceInput!) {
|
mutation($input: CreateAccessReviewSourceInput!) {
|
||||||
createAccessSource(input: $input) {
|
createAccessReviewSource(input: $input) {
|
||||||
accessSourceEdge {
|
accessReviewSourceEdge {
|
||||||
node { id }
|
node { id }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -992,7 +992,7 @@ func CreateAccessSource(c *testutil.Client, organizationID string, attrs ...Attr
|
|||||||
|
|
||||||
input := map[string]any{
|
input := map[string]any{
|
||||||
"organizationId": organizationID,
|
"organizationId": organizationID,
|
||||||
"name": a.getString("name", SafeName("AccessSource")),
|
"name": a.getString("name", SafeName("AccessReviewSource")),
|
||||||
}
|
}
|
||||||
if csvData := a.getStringPtr("csvData"); csvData != nil {
|
if csvData := a.getStringPtr("csvData"); csvData != nil {
|
||||||
input["csvData"] = *csvData
|
input["csvData"] = *csvData
|
||||||
@@ -1003,43 +1003,43 @@ func CreateAccessSource(c *testutil.Client, organizationID string, attrs ...Attr
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
CreateAccessSource struct {
|
CreateAccessReviewSource struct {
|
||||||
AccessSourceEdge struct {
|
AccessReviewSourceEdge struct {
|
||||||
Node struct {
|
Node struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
} `json:"node"`
|
} `json:"node"`
|
||||||
} `json:"accessSourceEdge"`
|
} `json:"accessReviewSourceEdge"`
|
||||||
} `json:"createAccessSource"`
|
} `json:"createAccessReviewSource"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := c.Execute(query, map[string]any{"input": input}, &result)
|
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
|
client *testutil.Client
|
||||||
organizationID string
|
organizationID string
|
||||||
attrs Attrs
|
attrs Attrs
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAccessSource(c *testutil.Client, organizationID string) *AccessSourceBuilder {
|
func NewAccessReviewSource(c *testutil.Client, organizationID string) *AccessReviewSourceBuilder {
|
||||||
return &AccessSourceBuilder{client: c, organizationID: organizationID, attrs: Attrs{}}
|
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
|
b.attrs["name"] = name
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *AccessSourceBuilder) WithCsvData(csvData string) *AccessSourceBuilder {
|
func (b *AccessReviewSourceBuilder) WithCsvData(csvData string) *AccessReviewSourceBuilder {
|
||||||
b.attrs["csvData"] = csvData
|
b.attrs["csvData"] = csvData
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *AccessSourceBuilder) Create() string {
|
func (b *AccessReviewSourceBuilder) Create() string {
|
||||||
return CreateAccessSource(b.client, b.organizationID, b.attrs)
|
return CreateAccessReviewSource(b.client, b.organizationID, b.attrs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateAccessReviewCampaign(c *testutil.Client, organizationID string, attrs ...Attrs) string {
|
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")),
|
"name": a.getString("name", SafeName("Campaign")),
|
||||||
}
|
}
|
||||||
|
|
||||||
if v, ok := a["accessSourceIds"]; ok {
|
if v, ok := a["accessReviewSourceIds"]; ok {
|
||||||
input["accessSourceIds"] = v
|
input["accessReviewSourceIds"] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
@@ -1100,8 +1100,8 @@ func (b *AccessReviewCampaignBuilder) WithName(name string) *AccessReviewCampaig
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *AccessReviewCampaignBuilder) WithAccessSourceIDs(ids []string) *AccessReviewCampaignBuilder {
|
func (b *AccessReviewCampaignBuilder) WithAccessReviewSourceIDs(ids []string) *AccessReviewCampaignBuilder {
|
||||||
b.attrs["accessSourceIds"] = ids
|
b.attrs["accessReviewSourceIds"] = ids
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
45
pkg/accessreview/actions.go
Normal file
45
pkg/accessreview/actions.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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:<entity>:<action>
|
||||||
|
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"
|
||||||
|
)
|
||||||
@@ -25,20 +25,9 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/page"
|
"go.probo.inc/probo/pkg/page"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CampaignService struct {
|
func (s *Service) CreateCampaign(
|
||||||
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(
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
req CreateAccessReviewCampaignRequest,
|
req CreateAccessReviewCampaignRequest,
|
||||||
) (*coredata.AccessReviewCampaign, error) {
|
) (*coredata.AccessReviewCampaign, error) {
|
||||||
if err := req.Validate(); err != nil {
|
if err := req.Validate(); err != nil {
|
||||||
@@ -47,7 +36,7 @@ func (s *CampaignService) Create(
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
campaign := &coredata.AccessReviewCampaign{
|
campaign := &coredata.AccessReviewCampaign{
|
||||||
ID: gid.New(s.scope.GetTenantID(), coredata.AccessReviewCampaignEntityType),
|
ID: gid.New(scope.GetTenantID(), coredata.AccessReviewCampaignEntityType),
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
@@ -60,13 +49,13 @@ func (s *CampaignService) Create(
|
|||||||
err := s.pg.WithTx(
|
err := s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Tx) error {
|
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)
|
return fmt.Errorf("cannot insert access review campaign: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, sourceID := range req.AccessSourceIDs {
|
for _, sourceID := range req.AccessReviewSourceIDs {
|
||||||
source := &coredata.AccessSource{}
|
source := &coredata.AccessReviewSource{}
|
||||||
if err := source.LoadByID(ctx, conn, s.scope, sourceID); err != nil {
|
if err := source.LoadByID(ctx, conn, scope, sourceID); err != nil {
|
||||||
return fmt.Errorf("cannot load access source %s: %w", sourceID, err)
|
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)
|
return fmt.Errorf("cannot create campaign: access source %s does not belong to the same organization", sourceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
scopeSystem := coredata.AccessReviewCampaignScopeSystem{
|
if err := s.upsertCampaignSource(ctx, conn, scope, campaign.ID, source); err != nil {
|
||||||
AccessReviewCampaignID: campaign.ID,
|
return fmt.Errorf("cannot snapshot scope source: %w", err)
|
||||||
AccessSourceID: sourceID,
|
|
||||||
}
|
|
||||||
if err := scopeSystem.Insert(ctx, conn, s.scope); err != nil {
|
|
||||||
return fmt.Errorf("cannot insert scope system: %w", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +78,9 @@ func (s *CampaignService) Create(
|
|||||||
return campaign, nil
|
return campaign, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CampaignService) Get(
|
func (s *Service) GetCampaign(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
campaignID gid.GID,
|
campaignID gid.GID,
|
||||||
) (*coredata.AccessReviewCampaign, error) {
|
) (*coredata.AccessReviewCampaign, error) {
|
||||||
campaign := &coredata.AccessReviewCampaign{}
|
campaign := &coredata.AccessReviewCampaign{}
|
||||||
@@ -102,7 +88,7 @@ func (s *CampaignService) Get(
|
|||||||
err := s.pg.WithConn(
|
err := s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
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)
|
return fmt.Errorf("cannot load campaign: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,8 +102,33 @@ func (s *CampaignService) Get(
|
|||||||
return campaign, nil
|
return campaign, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CampaignService) Update(
|
func (s *Service) GetCampaignSource(
|
||||||
ctx context.Context,
|
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,
|
req UpdateAccessReviewCampaignRequest,
|
||||||
) (*coredata.AccessReviewCampaign, error) {
|
) (*coredata.AccessReviewCampaign, error) {
|
||||||
if err := req.Validate(); err != nil {
|
if err := req.Validate(); err != nil {
|
||||||
@@ -129,11 +140,11 @@ func (s *CampaignService) Update(
|
|||||||
err := s.pg.WithTx(
|
err := s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Tx) error {
|
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)
|
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)
|
return fmt.Errorf("cannot load campaign: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +166,7 @@ func (s *CampaignService) Update(
|
|||||||
|
|
||||||
campaign.UpdatedAt = time.Now()
|
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)
|
return fmt.Errorf("cannot update campaign: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,19 +180,20 @@ func (s *CampaignService) Update(
|
|||||||
return campaign, nil
|
return campaign, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CampaignService) Delete(
|
func (s *Service) DeleteCampaign(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
campaignID gid.GID,
|
campaignID gid.GID,
|
||||||
) error {
|
) error {
|
||||||
return s.pg.WithTx(
|
return s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Tx) error {
|
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)
|
return fmt.Errorf("cannot lock campaign: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
campaign := &coredata.AccessReviewCampaign{}
|
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)
|
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)
|
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)
|
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,
|
ctx context.Context,
|
||||||
req AddCampaignScopeSourceRequest,
|
scope coredata.Scoper,
|
||||||
|
req AddCampaignSourceRequest,
|
||||||
) (*coredata.AccessReviewCampaign, error) {
|
) (*coredata.AccessReviewCampaign, error) {
|
||||||
campaign := &coredata.AccessReviewCampaign{}
|
campaign := &coredata.AccessReviewCampaign{}
|
||||||
|
|
||||||
err := s.pg.WithTx(
|
err := s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Tx) error {
|
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)
|
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)
|
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)
|
return fmt.Errorf("cannot add scope source: campaign status is %s, expected %s", campaign.Status, coredata.AccessReviewCampaignStatusDraft)
|
||||||
}
|
}
|
||||||
|
|
||||||
source := &coredata.AccessSource{}
|
source := &coredata.AccessReviewSource{}
|
||||||
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 %s: %w", req.AccessSourceID, err)
|
return fmt.Errorf("cannot load access source %s: %w", req.AccessReviewSourceID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if source.OrganizationID != campaign.OrganizationID {
|
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{
|
if err := s.upsertCampaignSource(ctx, conn, scope, campaign.ID, source); err != nil {
|
||||||
AccessReviewCampaignID: campaign.ID,
|
return fmt.Errorf("cannot snapshot scope source: %w", err)
|
||||||
AccessSourceID: req.AccessSourceID,
|
|
||||||
}
|
|
||||||
if err := scopeSystem.Upsert(ctx, conn, s.scope); err != nil {
|
|
||||||
return fmt.Errorf("cannot upsert scope system: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -247,20 +256,21 @@ func (s *CampaignService) AddScopeSource(
|
|||||||
return campaign, nil
|
return campaign, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CampaignService) RemoveScopeSource(
|
func (s *Service) RemoveCampaignSource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req RemoveCampaignScopeSourceRequest,
|
scope coredata.Scoper,
|
||||||
|
req RemoveCampaignSourceRequest,
|
||||||
) (*coredata.AccessReviewCampaign, error) {
|
) (*coredata.AccessReviewCampaign, error) {
|
||||||
campaign := &coredata.AccessReviewCampaign{}
|
campaign := &coredata.AccessReviewCampaign{}
|
||||||
|
|
||||||
err := s.pg.WithTx(
|
err := s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Tx) error {
|
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)
|
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)
|
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)
|
return fmt.Errorf("cannot remove scope source: campaign status is %s, expected DRAFT", campaign.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
scopeSystem := coredata.AccessReviewCampaignScopeSystem{
|
campaignSource := &coredata.AccessReviewCampaignSource{}
|
||||||
AccessReviewCampaignID: campaign.ID,
|
if err := campaignSource.DeleteByCampaignIDAndAccessReviewSourceID(ctx, conn, scope, campaign.ID, req.AccessReviewSourceID); err != nil {
|
||||||
AccessSourceID: req.AccessSourceID,
|
return fmt.Errorf("cannot delete campaign source: %w", err)
|
||||||
}
|
|
||||||
if err := scopeSystem.Delete(ctx, conn, s.scope); err != nil {
|
|
||||||
return fmt.Errorf("cannot delete scope system: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -286,8 +293,9 @@ func (s *CampaignService) RemoveScopeSource(
|
|||||||
return campaign, nil
|
return campaign, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CampaignService) Start(
|
func (s *Service) StartCampaign(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
campaignID gid.GID,
|
campaignID gid.GID,
|
||||||
) (*coredata.AccessReviewCampaign, error) {
|
) (*coredata.AccessReviewCampaign, error) {
|
||||||
campaign := &coredata.AccessReviewCampaign{}
|
campaign := &coredata.AccessReviewCampaign{}
|
||||||
@@ -295,11 +303,11 @@ func (s *CampaignService) Start(
|
|||||||
err := s.pg.WithTx(
|
err := s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Tx) error {
|
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)
|
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)
|
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)
|
return fmt.Errorf("cannot start campaign: status is %s, expected %s", campaign.Status, coredata.AccessReviewCampaignStatusDraft)
|
||||||
}
|
}
|
||||||
|
|
||||||
var sources coredata.AccessSources
|
var campaignSources coredata.AccessReviewCampaignSources
|
||||||
if err := sources.LoadScopeSourcesByCampaignID(ctx, conn, s.scope, campaign.ID); err != nil {
|
if err := campaignSources.LoadByCampaignID(ctx, conn, scope, campaign.ID); err != nil {
|
||||||
return fmt.Errorf("cannot load scope sources: %w", err)
|
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")
|
return fmt.Errorf("cannot start campaign: no scope sources configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,11 +329,11 @@ func (s *CampaignService) Start(
|
|||||||
campaign.StartedAt = &now
|
campaign.StartedAt = &now
|
||||||
campaign.UpdatedAt = 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)
|
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)
|
return fmt.Errorf("cannot queue source fetches: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,8 +347,9 @@ func (s *CampaignService) Start(
|
|||||||
return campaign, nil
|
return campaign, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CampaignService) Close(
|
func (s *Service) CloseCampaign(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
campaignID gid.GID,
|
campaignID gid.GID,
|
||||||
) (*coredata.AccessReviewCampaign, error) {
|
) (*coredata.AccessReviewCampaign, error) {
|
||||||
campaign := &coredata.AccessReviewCampaign{}
|
campaign := &coredata.AccessReviewCampaign{}
|
||||||
@@ -348,11 +357,11 @@ func (s *CampaignService) Close(
|
|||||||
err := s.pg.WithTx(
|
err := s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Tx) error {
|
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)
|
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)
|
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)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot count pending entries: %w", err)
|
return fmt.Errorf("cannot count pending entries: %w", err)
|
||||||
}
|
}
|
||||||
@@ -376,7 +385,7 @@ func (s *CampaignService) Close(
|
|||||||
campaign.CompletedAt = &now
|
campaign.CompletedAt = &now
|
||||||
campaign.UpdatedAt = 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)
|
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
|
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,
|
ctx context.Context,
|
||||||
tx pg.Tx,
|
tx pg.Tx,
|
||||||
|
scope coredata.Scoper,
|
||||||
campaignID gid.GID,
|
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 {
|
) error {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
for _, source := range sources {
|
for _, campaignSource := range campaignSources {
|
||||||
fetch := &coredata.AccessReviewCampaignSourceFetch{
|
attempt := &coredata.AccessReviewCampaignSourceFetchAttempt{
|
||||||
AccessReviewCampaignID: campaignID,
|
ID: gid.New(scope.GetTenantID(), coredata.AccessReviewCampaignSourceFetchAttemptEntityType),
|
||||||
AccessSourceID: source.ID,
|
AccessReviewCampaignSourceID: campaignSource.ID,
|
||||||
|
Status: coredata.AccessReviewCampaignSourceFetchStatusQueued,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
if err := fetch.UpsertQueued(ctx, tx, s.scope, now); err != nil {
|
if err := attempt.Insert(ctx, tx, scope); err != nil {
|
||||||
return fmt.Errorf("cannot queue source fetch %s: %w", source.ID, err)
|
return fmt.Errorf("cannot queue source fetch %s: %w", campaignSource.ID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CampaignService) Cancel(
|
func (s *Service) CancelCampaign(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
campaignID gid.GID,
|
campaignID gid.GID,
|
||||||
) (*coredata.AccessReviewCampaign, error) {
|
) (*coredata.AccessReviewCampaign, error) {
|
||||||
campaign := &coredata.AccessReviewCampaign{}
|
campaign := &coredata.AccessReviewCampaign{}
|
||||||
@@ -429,11 +472,11 @@ func (s *CampaignService) Cancel(
|
|||||||
err := s.pg.WithTx(
|
err := s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Tx) error {
|
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)
|
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)
|
return fmt.Errorf("cannot load campaign: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,7 +490,7 @@ func (s *CampaignService) Cancel(
|
|||||||
campaign.CompletedAt = &now
|
campaign.CompletedAt = &now
|
||||||
campaign.UpdatedAt = 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)
|
return fmt.Errorf("cannot update campaign: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,8 +504,9 @@ func (s *CampaignService) Cancel(
|
|||||||
return campaign, nil
|
return campaign, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CampaignService) ListForOrganizationID(
|
func (s *Service) ListCampaignsForOrganizationID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
cursor *page.Cursor[coredata.AccessReviewCampaignOrderField],
|
cursor *page.Cursor[coredata.AccessReviewCampaignOrderField],
|
||||||
) (*page.Page[*coredata.AccessReviewCampaign, coredata.AccessReviewCampaignOrderField], error) {
|
) (*page.Page[*coredata.AccessReviewCampaign, coredata.AccessReviewCampaignOrderField], error) {
|
||||||
@@ -471,7 +515,7 @@ func (s *CampaignService) ListForOrganizationID(
|
|||||||
err := s.pg.WithConn(
|
err := s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
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)
|
return fmt.Errorf("cannot load campaigns by organization: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,17 +529,18 @@ func (s *CampaignService) ListForOrganizationID(
|
|||||||
return page.NewPage(campaigns, cursor), nil
|
return page.NewPage(campaigns, cursor), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CampaignService) ListSourceFetches(
|
func (s *Service) ListCampaignSources(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
campaignID gid.GID,
|
campaignID gid.GID,
|
||||||
) (coredata.AccessReviewCampaignSourceFetches, error) {
|
) (coredata.AccessReviewCampaignSources, error) {
|
||||||
var fetches coredata.AccessReviewCampaignSourceFetches
|
var sources coredata.AccessReviewCampaignSources
|
||||||
|
|
||||||
err := s.pg.WithConn(
|
err := s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
if err := fetches.LoadByCampaignID(ctx, conn, s.scope, campaignID); err != nil {
|
if err := sources.LoadByCampaignID(ctx, conn, scope, campaignID); err != nil {
|
||||||
return fmt.Errorf("cannot load source fetches by campaign: %w", err)
|
return fmt.Errorf("cannot load campaign sources: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -505,11 +550,60 @@ func (s *CampaignService) ListSourceFetches(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetches, nil
|
return sources, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CampaignService) CountForOrganizationID(
|
func (s *Service) ListLatestFetchAttempts(
|
||||||
ctx context.Context,
|
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,
|
organizationID gid.GID,
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
var count int
|
var count int
|
||||||
@@ -519,7 +613,7 @@ func (s *CampaignService) CountForOrganizationID(
|
|||||||
func(ctx context.Context, conn pg.Querier) (err error) {
|
func(ctx context.Context, conn pg.Querier) (err error) {
|
||||||
campaigns := coredata.AccessReviewCampaigns{}
|
campaigns := coredata.AccessReviewCampaigns{}
|
||||||
|
|
||||||
count, err = campaigns.CountByOrganizationID(ctx, conn, s.scope, organizationID)
|
count, err = campaigns.CountByOrganizationID(ctx, conn, scope, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot count campaigns by organization: %w", err)
|
return fmt.Errorf("cannot count campaigns by organization: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ const campaignNameMaxLength = 255
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
CreateAccessReviewCampaignRequest struct {
|
CreateAccessReviewCampaignRequest struct {
|
||||||
OrganizationID gid.GID
|
OrganizationID gid.GID
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
FrameworkControls []string
|
FrameworkControls []string
|
||||||
AccessSourceIDs []gid.GID
|
AccessReviewSourceIDs []gid.GID
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateAccessReviewCampaignRequest struct {
|
UpdateAccessReviewCampaignRequest struct {
|
||||||
@@ -38,14 +38,14 @@ type (
|
|||||||
FrameworkControls *[]string
|
FrameworkControls *[]string
|
||||||
}
|
}
|
||||||
|
|
||||||
AddCampaignScopeSourceRequest struct {
|
AddCampaignSourceRequest struct {
|
||||||
CampaignID gid.GID
|
CampaignID gid.GID
|
||||||
AccessSourceID gid.GID
|
AccessReviewSourceID gid.GID
|
||||||
}
|
}
|
||||||
|
|
||||||
RemoveCampaignScopeSourceRequest struct {
|
RemoveCampaignSourceRequest struct {
|
||||||
CampaignID gid.GID
|
CampaignID gid.GID
|
||||||
AccessSourceID gid.GID
|
AccessReviewSourceID gid.GID
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ func (d *AnthropicDriver) ListAccounts(ctx context.Context) ([]AccountRecord, er
|
|||||||
IsAdmin: u.Role == "admin",
|
IsAdmin: u.Role == "admin",
|
||||||
ExternalID: u.ID,
|
ExternalID: u.ID,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
// added_at is an RFC 3339 datetime string; ignore parse
|
// added_at is an RFC 3339 datetime string; ignore parse
|
||||||
|
|||||||
@@ -103,8 +103,8 @@ func (d *AsanaDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error)
|
|||||||
FullName: u.Name,
|
FullName: u.Name,
|
||||||
ExternalID: u.GID,
|
ExternalID: u.GID,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ func (d *BetterStackDriver) ListAccounts(ctx context.Context) ([]AccountRecord,
|
|||||||
Active: betterStackActive(member.Type),
|
Active: betterStackActive(member.Type),
|
||||||
IsAdmin: betterStackIsAdmin(member.Attributes.Role),
|
IsAdmin: betterStackIsAdmin(member.Attributes.Role),
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
ExternalID: member.ID,
|
ExternalID: member.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ func (d *BitbucketDriver) ListAccounts(ctx context.Context) ([]AccountRecord, er
|
|||||||
FullName: fullName,
|
FullName: fullName,
|
||||||
ExternalID: m.User.AccountID,
|
ExternalID: m.User.AccountID,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
records = append(records, record)
|
records = append(records, record)
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ func (d *BrexDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error)
|
|||||||
IsAdmin: false,
|
IsAdmin: false,
|
||||||
ExternalID: u.ID,
|
ExternalID: u.ID,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
if record.Email != "" {
|
if record.Email != "" {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func (d *ClerkDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error)
|
|||||||
IsAdmin: false,
|
IsAdmin: false,
|
||||||
MFAStatus: clerkMFAStatus(u),
|
MFAStatus: clerkMFAStatus(u),
|
||||||
AuthMethod: clerkAuthMethod(u),
|
AuthMethod: clerkAuthMethod(u),
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
ExternalID: u.ID,
|
ExternalID: u.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,12 +211,12 @@ func clerkMFAStatus(u clerkUser) coredata.MFAStatus {
|
|||||||
return coredata.MFAStatusDisabled
|
return coredata.MFAStatusDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
func clerkAuthMethod(u clerkUser) coredata.AccessEntryAuthMethod {
|
func clerkAuthMethod(u clerkUser) coredata.AccessReviewEntryAuthMethod {
|
||||||
if u.PasswordEnabled {
|
if u.PasswordEnabled {
|
||||||
return coredata.AccessEntryAuthMethodPassword
|
return coredata.AccessReviewEntryAuthMethodPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
return coredata.AccessEntryAuthMethodUnknown
|
return coredata.AccessReviewEntryAuthMethodUnknown
|
||||||
}
|
}
|
||||||
|
|
||||||
func clerkUnixMillisToTime(unixMillis int64) *time.Time {
|
func clerkUnixMillisToTime(unixMillis int64) *time.Time {
|
||||||
|
|||||||
@@ -40,11 +40,11 @@ func TestClerkDriver(t *testing.T) {
|
|||||||
assert.Equal(t, "user_3EfkCEWmtIsoMD3rRxIpDsBOPzv", first.ExternalID)
|
assert.Equal(t, "user_3EfkCEWmtIsoMD3rRxIpDsBOPzv", first.ExternalID)
|
||||||
assert.Equal(t, "c@example.com", first.Email)
|
assert.Equal(t, "c@example.com", first.Email)
|
||||||
assert.Equal(t, "c c", first.FullName)
|
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)
|
require.NotNil(t, first.Active)
|
||||||
assert.True(t, *first.Active)
|
assert.True(t, *first.Active)
|
||||||
assert.Equal(t, coredata.MFAStatusDisabled, first.MFAStatus)
|
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.NotNil(t, first.CreatedAt)
|
||||||
assert.Nil(t, first.LastLogin)
|
assert.Nil(t, first.LastLogin)
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ func TestClerkDriver(t *testing.T) {
|
|||||||
assert.Equal(t, "a a", third.FullName)
|
assert.Equal(t, "a a", third.FullName)
|
||||||
require.NotNil(t, third.Active)
|
require.NotNil(t, third.Active)
|
||||||
assert.False(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) {
|
func TestClerkPrimaryEmail(t *testing.T) {
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ func (d *ClickUpDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
|
|||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
ExternalID: m.User.ID.String(),
|
ExternalID: m.User.ID.String(),
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.InvitePending != nil {
|
if m.InvitePending != nil {
|
||||||
|
|||||||
@@ -197,8 +197,8 @@ func (d *CloudflareDriver) queryAllMembers(ctx context.Context, accountID string
|
|||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
ExternalID: m.ID,
|
ExternalID: m.ID,
|
||||||
MFAStatus: mfaStatus,
|
MFAStatus: mfaStatus,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
if record.Email != "" {
|
if record.Email != "" {
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ func (d *CSVDriver) ListAccounts(_ context.Context) ([]AccountRecord, error) {
|
|||||||
|
|
||||||
record := AccountRecord{
|
record := AccountRecord{
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
if idx, ok := colIndex["email"]; ok && idx < len(row) {
|
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 idx, ok := colIndex["account_type"]; ok && idx < len(row) {
|
||||||
if strings.TrimSpace(strings.ToUpper(row[idx])) == "SERVICE_ACCOUNT" {
|
if strings.TrimSpace(strings.ToUpper(row[idx])) == "SERVICE_ACCOUNT" {
|
||||||
record.AccountType = coredata.AccessEntryAccountTypeServiceAccount
|
record.AccountType = coredata.AccessReviewEntryAccountTypeServiceAccount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,9 +123,9 @@ func (d *DatadogDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
accountType := coredata.AccessEntryAccountTypeUser
|
accountType := coredata.AccessReviewEntryAccountTypeUser
|
||||||
if u.Attributes.ServiceAccount {
|
if u.Attributes.ServiceAccount {
|
||||||
accountType = coredata.AccessEntryAccountTypeServiceAccount
|
accountType = coredata.AccessReviewEntryAccountTypeServiceAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
mfaStatus := coredata.MFAStatusDisabled
|
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
|
// Datadog's /api/v2/users does not expose the login method
|
||||||
// used (no allowed_login_methods in the schema), so the
|
// used (no allowed_login_methods in the schema), so the
|
||||||
// auth method is unknown.
|
// auth method is unknown.
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: accountType,
|
AccountType: accountType,
|
||||||
ExternalID: u.ID,
|
ExternalID: u.ID,
|
||||||
CreatedAt: parseRFC3339Ptr(u.Attributes.CreatedAt),
|
CreatedAt: parseRFC3339Ptr(u.Attributes.CreatedAt),
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ func TestDatadogDriver(t *testing.T) {
|
|||||||
assert.True(t, r.IsAdmin)
|
assert.True(t, r.IsAdmin)
|
||||||
assert.Equal(t, "Datadog Admin Role", r.Role)
|
assert.Equal(t, "Datadog Admin Role", r.Role)
|
||||||
assert.Equal(t, "Security Engineer", r.JobTitle)
|
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.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
|
// Second record exercises the inactive, non-admin, and service-account
|
||||||
// (MFA-disabled) branches.
|
// (MFA-disabled) branches.
|
||||||
@@ -57,6 +57,6 @@ func TestDatadogDriver(t *testing.T) {
|
|||||||
assert.False(t, *r2.Active)
|
assert.False(t, *r2.Active)
|
||||||
assert.False(t, r2.IsAdmin)
|
assert.False(t, r2.IsAdmin)
|
||||||
assert.Equal(t, "Datadog Standard Role", r2.Role)
|
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)
|
assert.Equal(t, coredata.MFAStatusDisabled, r2.MFAStatus)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,8 +99,8 @@ func (d *DocuSignDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err
|
|||||||
IsAdmin: strings.EqualFold(u.IsAdmin, "True"),
|
IsAdmin: strings.EqualFold(u.IsAdmin, "True"),
|
||||||
ExternalID: u.UserID,
|
ExternalID: u.UserID,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.LastLogin != "" {
|
if u.LastLogin != "" {
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ type AccountRecord struct {
|
|||||||
Active *bool
|
Active *bool
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
MFAStatus coredata.MFAStatus
|
MFAStatus coredata.MFAStatus
|
||||||
AuthMethod coredata.AccessEntryAuthMethod
|
AuthMethod coredata.AccessReviewEntryAuthMethod
|
||||||
AccountType coredata.AccessEntryAccountType
|
AccountType coredata.AccessReviewEntryAccountType
|
||||||
LastLogin *time.Time
|
LastLogin *time.Time
|
||||||
CreatedAt *time.Time
|
CreatedAt *time.Time
|
||||||
ExternalID string // system-specific user ID
|
ExternalID string // system-specific user ID
|
||||||
|
|||||||
@@ -108,9 +108,9 @@ func (d *GitHubDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
|
|||||||
fullName = m.Login
|
fullName = m.Login
|
||||||
}
|
}
|
||||||
|
|
||||||
accountType := coredata.AccessEntryAccountTypeUser
|
accountType := coredata.AccessReviewEntryAccountTypeUser
|
||||||
if m.Type == "Bot" {
|
if m.Type == "Bot" {
|
||||||
accountType = coredata.AccessEntryAccountTypeServiceAccount
|
accountType = coredata.AccessReviewEntryAccountTypeServiceAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
mfaStatus := coredata.MFAStatusUnknown
|
mfaStatus := coredata.MFAStatusUnknown
|
||||||
@@ -130,7 +130,7 @@ func (d *GitHubDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
|
|||||||
Active: new(membership.State == "active"),
|
Active: new(membership.State == "active"),
|
||||||
IsAdmin: membership.Role == "admin",
|
IsAdmin: membership.Role == "admin",
|
||||||
MFAStatus: mfaStatus,
|
MFAStatus: mfaStatus,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: accountType,
|
AccountType: accountType,
|
||||||
ExternalID: strconv.FormatInt(m.ID, 10),
|
ExternalID: strconv.FormatInt(m.ID, 10),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ func (d *GitLabDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
|
|||||||
Active: &active,
|
Active: &active,
|
||||||
IsAdmin: m.AccessLevel >= 50, // 50 = Owner
|
IsAdmin: m.AccessLevel >= 50, // 50 = Owner
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
ExternalID: strconv.FormatInt(m.ID, 10),
|
ExternalID: strconv.FormatInt(m.ID, 10),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ func (d *GoogleWorkspaceDriver) ListAccounts(ctx context.Context) ([]AccountReco
|
|||||||
IsAdmin: u.IsAdmin,
|
IsAdmin: u.IsAdmin,
|
||||||
ExternalID: u.Id,
|
ExternalID: u.Id,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodSSO,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodSSO,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.IsEnrolledIn2Sv {
|
if u.IsEnrolledIn2Sv {
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ func (d *GrafanaDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
|
|||||||
Role: strings.TrimSpace(u.Role),
|
Role: strings.TrimSpace(u.Role),
|
||||||
IsAdmin: strings.EqualFold(strings.TrimSpace(u.Role), "Admin"),
|
IsAdmin: strings.EqualFold(strings.TrimSpace(u.Role), "Admin"),
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
ExternalID: strconv.Itoa(u.UserID),
|
ExternalID: strconv.Itoa(u.UserID),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,8 +159,8 @@ func (d *HerokuDriver) listTeamMembers(ctx context.Context) ([]AccountRecord, er
|
|||||||
Role: m.Role,
|
Role: m.Role,
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
MFAStatus: mfaStatus,
|
MFAStatus: mfaStatus,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
ExternalID: externalID,
|
ExternalID: externalID,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,8 +268,8 @@ func herokuPersonalRecord(externalID, email, role string, isAdmin bool) AccountR
|
|||||||
Role: role,
|
Role: role,
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
ExternalID: externalID,
|
ExternalID: externalID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ func (d *HubSpotDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
|
|||||||
IsAdmin: u.SuperAdmin,
|
IsAdmin: u.SuperAdmin,
|
||||||
ExternalID: u.ID,
|
ExternalID: u.ID,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
if record.Email != "" || record.ExternalID != "" {
|
if record.Email != "" || record.ExternalID != "" {
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ func (d *IntercomDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err
|
|||||||
IsAdmin: false, // Intercom API does not expose admin role information
|
IsAdmin: false, // Intercom API does not expose admin role information
|
||||||
ExternalID: a.ID,
|
ExternalID: a.ID,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
if record.Email != "" || record.FullName != "" {
|
if record.Email != "" || record.FullName != "" {
|
||||||
|
|||||||
@@ -88,9 +88,9 @@ func (d *LinearDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, u := range resp.Data.Users.Nodes {
|
for _, u := range resp.Data.Users.Nodes {
|
||||||
accountType := coredata.AccessEntryAccountTypeUser
|
accountType := coredata.AccessReviewEntryAccountTypeUser
|
||||||
if strings.HasSuffix(u.Email, ".linear.app") {
|
if strings.HasSuffix(u.Email, ".linear.app") {
|
||||||
accountType = coredata.AccessEntryAccountTypeServiceAccount
|
accountType = coredata.AccessReviewEntryAccountTypeServiceAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
record := AccountRecord{
|
record := AccountRecord{
|
||||||
@@ -101,7 +101,7 @@ func (d *LinearDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
|
|||||||
IsAdmin: u.Admin,
|
IsAdmin: u.Admin,
|
||||||
ExternalID: u.ID,
|
ExternalID: u.ID,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: accountType,
|
AccountType: accountType,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ func (d *MetabaseDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err
|
|||||||
IsAdmin: u.IsSuperuser,
|
IsAdmin: u.IsSuperuser,
|
||||||
ExternalID: strconv.Itoa(u.ID),
|
ExternalID: strconv.Itoa(u.ID),
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
if t, ok := parseMetabaseTimestamp(u.LastLogin); ok {
|
if t, ok := parseMetabaseTimestamp(u.LastLogin); ok {
|
||||||
|
|||||||
@@ -178,8 +178,8 @@ func (d *Microsoft365Driver) ListAccounts(ctx context.Context) ([]AccountRecord,
|
|||||||
Active: &active,
|
Active: &active,
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodSSO,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodSSO,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
ExternalID: u.ID,
|
ExternalID: u.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ func (d *MondayDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
|
|||||||
Active: &active,
|
Active: &active,
|
||||||
IsAdmin: u.IsAdmin,
|
IsAdmin: u.IsAdmin,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
ExternalID: u.ID,
|
ExternalID: u.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ func (r *qoveryNameResolver) ResolveInstanceName(ctx context.Context) (string, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// renderNameResolver resolves the Render workspace (owner) name from
|
// renderNameResolver resolves the Render workspace (owner) name from
|
||||||
// GET /v1/owners/{ownerId}, used to title the AccessSource "Render <name>".
|
// GET /v1/owners/{ownerId}, used to title the AccessReviewSource "Render <name>".
|
||||||
type renderNameResolver struct {
|
type renderNameResolver struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
ownerID string
|
ownerID string
|
||||||
@@ -651,7 +651,7 @@ func (r *anthropicNameResolver) ResolveInstanceName(ctx context.Context) (string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sendGridNameResolver resolves the SendGrid account's company name from
|
// 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 {
|
type sendGridNameResolver struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
@@ -1062,7 +1062,7 @@ func (r *pagerdutyNameResolver) ResolveInstanceName(_ context.Context) (string,
|
|||||||
|
|
||||||
// datadogNameResolver returns the Datadog site/region label stored in
|
// datadogNameResolver returns the Datadog site/region label stored in
|
||||||
// connector settings (e.g. "US3"), captured during the OAuth callback. No
|
// connector settings (e.g. "US3"), captured during the OAuth callback. No
|
||||||
// HTTP call is required; the AccessSource title becomes "Datadog <region>".
|
// HTTP call is required; the AccessReviewSource title becomes "Datadog <region>".
|
||||||
// Org-name resolution is intentionally omitted to keep scopes to
|
// Org-name resolution is intentionally omitted to keep scopes to
|
||||||
// user_access_read (the org name endpoint needs org_management).
|
// user_access_read (the org name endpoint needs org_management).
|
||||||
type datadogNameResolver struct {
|
type datadogNameResolver struct {
|
||||||
@@ -1132,7 +1132,7 @@ func (r *oktaNameResolver) ResolveInstanceName(ctx context.Context) (string, err
|
|||||||
|
|
||||||
// zendeskNameResolver returns the Zendesk subdomain stored in connector
|
// zendeskNameResolver returns the Zendesk subdomain stored in connector
|
||||||
// settings (e.g. "acme" for acme.zendesk.com), captured at connect time. No
|
// settings (e.g. "acme" for acme.zendesk.com), captured at connect time. No
|
||||||
// HTTP call is required; the AccessSource title becomes "Zendesk <subdomain>".
|
// HTTP call is required; the AccessReviewSource title becomes "Zendesk <subdomain>".
|
||||||
// Account-name resolution is intentionally omitted to keep the scope to
|
// Account-name resolution is intentionally omitted to keep the scope to
|
||||||
// users:read (Zendesk exposes no human account name on that scope).
|
// users:read (Zendesk exposes no human account name on that scope).
|
||||||
type zendeskNameResolver struct {
|
type zendeskNameResolver struct {
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ func (d *NeonDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error)
|
|||||||
Active: new(m.User.DeactivatedAt == ""),
|
Active: new(m.User.DeactivatedAt == ""),
|
||||||
IsAdmin: neonIsAdmin(m.Member.Role),
|
IsAdmin: neonIsAdmin(m.Member.Role),
|
||||||
MFAStatus: neonMFAStatus(m.User.HasMFA),
|
MFAStatus: neonMFAStatus(m.User.HasMFA),
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
ExternalID: neonExternalID(m),
|
ExternalID: neonExternalID(m),
|
||||||
CreatedAt: parseRFC3339Ptr(m.Member.JoinedAt),
|
CreatedAt: parseRFC3339Ptr(m.Member.JoinedAt),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ func (d *NetlifyDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
|
|||||||
Role: m.Role,
|
Role: m.Role,
|
||||||
ExternalID: m.ID,
|
ExternalID: m.ID,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
records = append(records, record)
|
records = append(records, record)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ func (d *NotionDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, u := range resp.Results {
|
for _, u := range resp.Results {
|
||||||
accountType := coredata.AccessEntryAccountTypeUser
|
accountType := coredata.AccessReviewEntryAccountTypeUser
|
||||||
if u.Type == "bot" {
|
if u.Type == "bot" {
|
||||||
accountType = coredata.AccessEntryAccountTypeServiceAccount
|
accountType = coredata.AccessReviewEntryAccountTypeServiceAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
var email string
|
var email string
|
||||||
@@ -84,7 +84,7 @@ func (d *NotionDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
|
|||||||
IsAdmin: false,
|
IsAdmin: false,
|
||||||
ExternalID: u.ID,
|
ExternalID: u.ID,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: accountType,
|
AccountType: accountType,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ func (d *OktaDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error)
|
|||||||
JobTitle: u.Profile.Title,
|
JobTitle: u.Profile.Title,
|
||||||
Active: oktaActive(u.Status),
|
Active: oktaActive(u.Status),
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
ExternalID: u.ID,
|
ExternalID: u.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ func (d *OnePasswordDriver) ListAccounts(ctx context.Context) ([]AccountRecord,
|
|||||||
Active: new(u.Active),
|
Active: new(u.Active),
|
||||||
ExternalID: u.ID,
|
ExternalID: u.ID,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
if record.FullName == "" && u.Name.Formatted != "" {
|
if record.FullName == "" && u.Name.Formatted != "" {
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ func (d *OnePasswordUsersAPIDriver) ListAccounts(ctx context.Context) ([]Account
|
|||||||
Active: new(u.State == "ACTIVE"),
|
Active: new(u.State == "ACTIVE"),
|
||||||
ExternalID: u.ID,
|
ExternalID: u.ID,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.CreateTime != "" {
|
if u.CreateTime != "" {
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ func (d *OpenAIDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
|
|||||||
IsAdmin: u.Role == "owner",
|
IsAdmin: u.Role == "owner",
|
||||||
ExternalID: u.ID,
|
ExternalID: u.ID,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.AddedAt != 0 {
|
if u.AddedAt != 0 {
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ func (d *PagerDutyDriver) ListAccounts(ctx context.Context) ([]AccountRecord, er
|
|||||||
Role: u.Role,
|
Role: u.Role,
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
ExternalID: u.ID,
|
ExternalID: u.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -296,8 +296,8 @@ func posthogAccountRecord(member posthogMember) AccountRecord {
|
|||||||
IsAdmin: posthogIsAdmin(member.Level),
|
IsAdmin: posthogIsAdmin(member.Level),
|
||||||
ExternalID: member.User.UUID,
|
ExternalID: member.User.UUID,
|
||||||
MFAStatus: posthogMFAStatus(member.Is2FAEnabled),
|
MFAStatus: posthogMFAStatus(member.Is2FAEnabled),
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
if record.ExternalID == "" {
|
if record.ExternalID == "" {
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ func (d *ProboMembershipsDriver) ListAccounts(ctx context.Context) ([]AccountRec
|
|||||||
ExternalID: account.ID.String(),
|
ExternalID: account.ID.String(),
|
||||||
CreatedAt: &createdAt,
|
CreatedAt: &createdAt,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ func (d *QoveryDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
|
|||||||
Role: qoveryRole(member.Role),
|
Role: qoveryRole(member.Role),
|
||||||
IsAdmin: qoveryIsAdmin(member.Role),
|
IsAdmin: qoveryIsAdmin(member.Role),
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
ExternalID: member.ID,
|
ExternalID: member.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,8 +103,8 @@ func (d *RenderDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
|
|||||||
Active: renderActive(member.Status),
|
Active: renderActive(member.Status),
|
||||||
IsAdmin: renderIsAdmin(member.Role),
|
IsAdmin: renderIsAdmin(member.Role),
|
||||||
MFAStatus: renderMFAStatus(member.MFAEnabled),
|
MFAStatus: renderMFAStatus(member.MFAEnabled),
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
ExternalID: member.UserID,
|
ExternalID: member.UserID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ func TestRenderDriverListAccounts(t *testing.T) {
|
|||||||
assert.Equal(t, "Admin", records[0].Role)
|
assert.Equal(t, "Admin", records[0].Role)
|
||||||
assert.True(t, records[0].IsAdmin)
|
assert.True(t, records[0].IsAdmin)
|
||||||
assert.Equal(t, coredata.MFAStatusEnabled, records[0].MFAStatus)
|
assert.Equal(t, coredata.MFAStatusEnabled, records[0].MFAStatus)
|
||||||
assert.Equal(t, coredata.AccessEntryAccountTypeUser, records[0].AccountType)
|
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, records[0].AccountType)
|
||||||
assert.Equal(t, coredata.AccessEntryAuthMethodUnknown, records[0].AuthMethod)
|
assert.Equal(t, coredata.AccessReviewEntryAuthMethodUnknown, records[0].AuthMethod)
|
||||||
assert.Equal(t, "usr-000000000000000000a1", records[0].ExternalID)
|
assert.Equal(t, "usr-000000000000000000a1", records[0].ExternalID)
|
||||||
require.NotNil(t, records[0].Active)
|
require.NotNil(t, records[0].Active)
|
||||||
assert.True(t, *records[0].Active)
|
assert.True(t, *records[0].Active)
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ func (d *ResendDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
|
|||||||
IsAdmin: false,
|
IsAdmin: false,
|
||||||
ExternalID: k.ID,
|
ExternalID: k.ID,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeServiceAccount,
|
AccountType: coredata.AccessReviewEntryAccountTypeServiceAccount,
|
||||||
}
|
}
|
||||||
|
|
||||||
if k.CreatedAt != "" {
|
if k.CreatedAt != "" {
|
||||||
|
|||||||
@@ -38,5 +38,5 @@ func TestResendDriver(t *testing.T) {
|
|||||||
r := records[0]
|
r := records[0]
|
||||||
assert.NotEmpty(t, r.FullName)
|
assert.NotEmpty(t, r.FullName)
|
||||||
assert.NotEmpty(t, r.ExternalID)
|
assert.NotEmpty(t, r.ExternalID)
|
||||||
assert.Equal(t, coredata.AccessEntryAccountTypeServiceAccount, r.AccountType)
|
assert.Equal(t, coredata.AccessReviewEntryAccountTypeServiceAccount, r.AccountType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ func (d *SendGridDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err
|
|||||||
ExternalID: strings.TrimSpace(teammate.Username),
|
ExternalID: strings.TrimSpace(teammate.Username),
|
||||||
MFAStatus: mfaStatus,
|
MFAStatus: mfaStatus,
|
||||||
AuthMethod: sendGridAuthMethod(teammate),
|
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
|
// authenticated through SSO (native or partner) is SSO; otherwise they sign in
|
||||||
// with SendGrid's own credentials. Both flags are always present on the
|
// with SendGrid's own credentials. Both flags are always present on the
|
||||||
// teammate payload, so this is a definitive signal.
|
// 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 {
|
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
|
// sendGridMFAStatus derives a teammate's MFA status from the auto-set 2fa
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ func TestSendGridDriver(t *testing.T) {
|
|||||||
assert.Equal(t, "Owner", owner.Role)
|
assert.Equal(t, "Owner", owner.Role)
|
||||||
assert.True(t, owner.IsAdmin)
|
assert.True(t, owner.IsAdmin)
|
||||||
assert.Equal(t, "owner@example.com", owner.ExternalID)
|
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.
|
// 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
|
// 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
|
// 2fa_exempt and 2fa_required, so the MFA signal is ambiguous and the
|
||||||
// driver reports Unknown rather than guessing from scope ordering.
|
// driver reports Unknown rather than guessing from scope ordering.
|
||||||
@@ -66,7 +66,7 @@ func TestSendGridDriver(t *testing.T) {
|
|||||||
assert.False(t, teammate.IsAdmin)
|
assert.False(t, teammate.IsAdmin)
|
||||||
// Non-unified teammate: username is a handle distinct from the email.
|
// Non-unified teammate: username is a handle distinct from the email.
|
||||||
assert.Equal(t, "taylor-teammate", teammate.ExternalID)
|
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)
|
assert.Equal(t, coredata.MFAStatusEnabled, teammate.MFAStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ func (d *SentryDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
|
|||||||
ExternalID: m.ID,
|
ExternalID: m.ID,
|
||||||
MFAStatus: mfaStatus,
|
MFAStatus: mfaStatus,
|
||||||
AuthMethod: authMethod,
|
AuthMethod: authMethod,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.User != nil && m.User.LastLogin != "" {
|
if m.User != nil && m.User.LastLogin != "" {
|
||||||
@@ -216,14 +216,14 @@ func sentryNextLink(header string) string {
|
|||||||
return ""
|
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"] {
|
if flags["sso:linked"] {
|
||||||
return coredata.AccessEntryAuthMethodSSO
|
return coredata.AccessReviewEntryAuthMethodSSO
|
||||||
}
|
}
|
||||||
|
|
||||||
if user != nil && user.HasPasswordAuth {
|
if user != nil && user.HasPasswordAuth {
|
||||||
return coredata.AccessEntryAuthMethodPassword
|
return coredata.AccessReviewEntryAuthMethodPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
return coredata.AccessEntryAuthMethodUnknown
|
return coredata.AccessReviewEntryAuthMethodUnknown
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ func (d *SigNozDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
|
|||||||
Active: sigNozActiveStatus(u.Status),
|
Active: sigNozActiveStatus(u.Status),
|
||||||
IsAdmin: u.IsRoot || strings.EqualFold(role, "Admin"),
|
IsAdmin: u.IsRoot || strings.EqualFold(role, "Admin"),
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
ExternalID: strings.TrimSpace(u.ID),
|
ExternalID: strings.TrimSpace(u.ID),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,9 +91,9 @@ func (d *SlackDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error)
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
accountType := coredata.AccessEntryAccountTypeUser
|
accountType := coredata.AccessReviewEntryAccountTypeUser
|
||||||
if m.IsBot || m.IsAppUser {
|
if m.IsBot || m.IsAppUser {
|
||||||
accountType = coredata.AccessEntryAccountTypeServiceAccount
|
accountType = coredata.AccessReviewEntryAccountTypeServiceAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
record := AccountRecord{
|
record := AccountRecord{
|
||||||
@@ -105,7 +105,7 @@ func (d *SlackDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error)
|
|||||||
IsAdmin: m.IsAdmin || m.IsOwner || m.IsPrimaryOwner,
|
IsAdmin: m.IsAdmin || m.IsOwner || m.IsPrimaryOwner,
|
||||||
ExternalID: m.ID,
|
ExternalID: m.ID,
|
||||||
MFAStatus: slackMFAStatus(m.Has2FA),
|
MFAStatus: slackMFAStatus(m.Has2FA),
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: accountType,
|
AccountType: accountType,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ func (d *SupabaseDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err
|
|||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
ExternalID: m.UserID,
|
ExternalID: m.UserID,
|
||||||
MFAStatus: mfaStatus,
|
MFAStatus: mfaStatus,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
records = append(records, record)
|
records = append(records, record)
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ func (d *TailscaleDriver) ListAccounts(ctx context.Context) ([]AccountRecord, er
|
|||||||
// Tailscale has no local credentials; it always delegates
|
// Tailscale has no local credentials; it always delegates
|
||||||
// authentication to an upstream identity provider, so every
|
// authentication to an upstream identity provider, so every
|
||||||
// account is SSO regardless of which IdP backs the tailnet.
|
// account is SSO regardless of which IdP backs the tailnet.
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodSSO,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodSSO,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.Created != "" {
|
if u.Created != "" {
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ func (d *TallyDriver) listUsers(ctx context.Context) ([]AccountRecord, error) {
|
|||||||
Active: new(!u.IsDeleted),
|
Active: new(!u.IsDeleted),
|
||||||
ExternalID: u.ID,
|
ExternalID: u.ID,
|
||||||
MFAStatus: mfaStatus,
|
MFAStatus: mfaStatus,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
CreatedAt: new(u.CreatedAt),
|
CreatedAt: new(u.CreatedAt),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,8 +176,8 @@ func (d *TallyDriver) listInvites(ctx context.Context) ([]AccountRecord, error)
|
|||||||
Active: new(false),
|
Active: new(false),
|
||||||
ExternalID: inv.ID,
|
ExternalID: inv.ID,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
Role: "Invited",
|
Role: "Invited",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ func (d *VercelDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
|
|||||||
Active: &confirmed,
|
Active: &confirmed,
|
||||||
IsAdmin: m.Role == "OWNER" || m.Role == "owner",
|
IsAdmin: m.Role == "OWNER" || m.Role == "owner",
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
ExternalID: m.UID,
|
ExternalID: m.UID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,8 +138,8 @@ func zendeskRecord(u zendeskUser) AccountRecord {
|
|||||||
MFAStatus: mfaStatus,
|
MFAStatus: mfaStatus,
|
||||||
// Zendesk's users API does not expose the sign-in method
|
// Zendesk's users API does not expose the sign-in method
|
||||||
// (password / SSO / social), so the auth method is unknown.
|
// (password / SSO / social), so the auth method is unknown.
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
ExternalID: strconv.FormatInt(u.ID, 10),
|
ExternalID: strconv.FormatInt(u.ID, 10),
|
||||||
LastLogin: parseRFC3339Ptr(lastLogin),
|
LastLogin: parseRFC3339Ptr(lastLogin),
|
||||||
CreatedAt: parseRFC3339Ptr(u.CreatedAt),
|
CreatedAt: parseRFC3339Ptr(u.CreatedAt),
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ func TestZendeskDriver(t *testing.T) {
|
|||||||
assert.True(t, *r.Active)
|
assert.True(t, *r.Active)
|
||||||
assert.True(t, r.IsAdmin)
|
assert.True(t, r.IsAdmin)
|
||||||
assert.Equal(t, "admin", r.Role)
|
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.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.LastLogin)
|
||||||
require.NotNil(t, r.CreatedAt)
|
require.NotNil(t, r.CreatedAt)
|
||||||
|
|
||||||
|
|||||||
@@ -27,35 +27,31 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
AccessEntryService struct {
|
RecordAccessReviewEntryDecisionRequest struct {
|
||||||
pg *pg.Client
|
|
||||||
scope coredata.Scoper
|
|
||||||
}
|
|
||||||
|
|
||||||
RecordAccessEntryDecisionRequest struct {
|
|
||||||
EntryID gid.GID
|
EntryID gid.GID
|
||||||
Decision coredata.AccessEntryDecision
|
Decision coredata.AccessReviewEntryDecision
|
||||||
DecisionNote *string
|
DecisionNote *string
|
||||||
DecidedByID *gid.GID
|
DecidedByID *gid.GID
|
||||||
}
|
}
|
||||||
|
|
||||||
FlagAccessEntryRequest struct {
|
FlagAccessReviewEntryRequest struct {
|
||||||
EntryID gid.GID
|
EntryID gid.GID
|
||||||
Flags []coredata.AccessEntryFlag
|
Flags []coredata.AccessReviewEntryFlag
|
||||||
FlagReasons []string
|
FlagReasons []string
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s AccessEntryService) Get(
|
func (s *Service) GetEntry(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
entryID gid.GID,
|
entryID gid.GID,
|
||||||
) (*coredata.AccessEntry, error) {
|
) (*coredata.AccessReviewEntry, error) {
|
||||||
entry := &coredata.AccessEntry{}
|
entry := &coredata.AccessReviewEntry{}
|
||||||
|
|
||||||
err := s.pg.WithConn(
|
err := s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
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 {
|
if err != nil {
|
||||||
@@ -65,31 +61,32 @@ func (s AccessEntryService) Get(
|
|||||||
return entry, nil
|
return entry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccessEntryService) RecordDecision(
|
func (s *Service) RecordDecision(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req RecordAccessEntryDecisionRequest,
|
scope coredata.Scoper,
|
||||||
) (*coredata.AccessEntry, error) {
|
req RecordAccessReviewEntryDecisionRequest,
|
||||||
if req.Decision == coredata.AccessEntryDecisionPending {
|
) (*coredata.AccessReviewEntry, error) {
|
||||||
|
if req.Decision == coredata.AccessReviewEntryDecisionPending {
|
||||||
return nil, fmt.Errorf("cannot decide access entry: invalid decision %q", req.Decision)
|
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) == "" {
|
if req.DecisionNote == nil || strings.TrimSpace(*req.DecisionNote) == "" {
|
||||||
return nil, fmt.Errorf("cannot decide access entry: note is required for non-approved decisions")
|
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(
|
err := s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Tx) error {
|
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)
|
return fmt.Errorf("cannot load access entry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
campaign := &coredata.AccessReviewCampaign{}
|
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)
|
return fmt.Errorf("cannot load campaign: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,34 +102,34 @@ func (s AccessEntryService) RecordDecision(
|
|||||||
|
|
||||||
entry.UpdatedAt = now
|
entry.UpdatedAt = now
|
||||||
if entry.Flags == nil {
|
if entry.Flags == nil {
|
||||||
entry.Flags = []coredata.AccessEntryFlag{}
|
entry.Flags = []coredata.AccessReviewEntryFlag{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.FlagReasons == nil {
|
if entry.FlagReasons == nil {
|
||||||
entry.FlagReasons = []string{}
|
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 {
|
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)
|
return fmt.Errorf("cannot record access entry decision: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
history := &coredata.AccessEntryDecisionHistory{
|
history := &coredata.AccessReviewEntryDecisionHistory{
|
||||||
ID: gid.New(s.scope.GetTenantID(), coredata.AccessEntryDecisionHistoryEntityType),
|
ID: gid.New(scope.GetTenantID(), coredata.AccessReviewEntryDecisionHistoryEntityType),
|
||||||
OrganizationID: entry.OrganizationID,
|
OrganizationID: entry.OrganizationID,
|
||||||
AccessEntry: entry.ID,
|
AccessReviewEntry: entry.ID,
|
||||||
Decision: entry.Decision,
|
Decision: entry.Decision,
|
||||||
DecisionNote: entry.DecisionNote,
|
DecisionNote: entry.DecisionNote,
|
||||||
DecidedBy: entry.DecidedBy,
|
DecidedBy: entry.DecidedBy,
|
||||||
DecidedAt: *entry.DecidedAt,
|
DecidedAt: *entry.DecidedAt,
|
||||||
CreatedAt: now,
|
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)
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot reload access entry after decision: %w", err)
|
return nil, fmt.Errorf("cannot reload access entry after decision: %w", err)
|
||||||
}
|
}
|
||||||
@@ -151,16 +148,17 @@ func (s AccessEntryService) RecordDecision(
|
|||||||
return updatedEntry, nil
|
return updatedEntry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccessEntryService) RecordDecisions(
|
func (s *Service) RecordDecisions(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
decisions []RecordAccessEntryDecisionRequest,
|
scope coredata.Scoper,
|
||||||
) ([]*coredata.AccessEntry, error) {
|
decisions []RecordAccessReviewEntryDecisionRequest,
|
||||||
|
) ([]*coredata.AccessReviewEntry, error) {
|
||||||
for _, d := range decisions {
|
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)
|
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) == "" {
|
if d.DecisionNote == nil || strings.TrimSpace(*d.DecisionNote) == "" {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"cannot bulk decide access entries: note is required for non-approved decisions on entry %s",
|
"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)
|
verifiedCampaigns := make(map[gid.GID]bool)
|
||||||
|
|
||||||
for _, d := range decisions {
|
for _, d := range decisions {
|
||||||
entry := &coredata.AccessEntry{}
|
entry := &coredata.AccessReviewEntry{}
|
||||||
if err := entry.LoadByID(ctx, conn, s.scope, d.EntryID); err != nil {
|
if err := entry.LoadByID(ctx, conn, scope, d.EntryID); err != nil {
|
||||||
return fmt.Errorf("cannot load access entry %s: %w", d.EntryID, err)
|
return fmt.Errorf("cannot load access entry %s: %w", d.EntryID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !verifiedCampaigns[entry.AccessReviewCampaignID] {
|
if !verifiedCampaigns[entry.AccessReviewCampaignID] {
|
||||||
campaign := &coredata.AccessReviewCampaign{}
|
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)
|
return fmt.Errorf("cannot load campaign: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,34 +207,34 @@ func (s AccessEntryService) RecordDecisions(
|
|||||||
|
|
||||||
entry.UpdatedAt = now
|
entry.UpdatedAt = now
|
||||||
if entry.Flags == nil {
|
if entry.Flags == nil {
|
||||||
entry.Flags = []coredata.AccessEntryFlag{}
|
entry.Flags = []coredata.AccessReviewEntryFlag{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.FlagReasons == nil {
|
if entry.FlagReasons == nil {
|
||||||
entry.FlagReasons = []string{}
|
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 {
|
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)
|
return fmt.Errorf("cannot record decision for entry %s: %w", d.EntryID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
history := &coredata.AccessEntryDecisionHistory{
|
history := &coredata.AccessReviewEntryDecisionHistory{
|
||||||
ID: gid.New(s.scope.GetTenantID(), coredata.AccessEntryDecisionHistoryEntityType),
|
ID: gid.New(scope.GetTenantID(), coredata.AccessReviewEntryDecisionHistoryEntityType),
|
||||||
OrganizationID: entry.OrganizationID,
|
OrganizationID: entry.OrganizationID,
|
||||||
AccessEntry: entry.ID,
|
AccessReviewEntry: entry.ID,
|
||||||
Decision: entry.Decision,
|
Decision: entry.Decision,
|
||||||
DecisionNote: entry.DecisionNote,
|
DecisionNote: entry.DecisionNote,
|
||||||
DecidedBy: entry.DecidedBy,
|
DecidedBy: entry.DecidedBy,
|
||||||
DecidedAt: *entry.DecidedAt,
|
DecidedAt: *entry.DecidedAt,
|
||||||
CreatedAt: now,
|
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)
|
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)
|
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 {
|
for i, id := range entryIDs {
|
||||||
entry, err := s.Get(ctx, id)
|
entry, err := s.GetEntry(ctx, scope, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot reload access entry %s: %w", id, err)
|
return nil, fmt.Errorf("cannot reload access entry %s: %w", id, err)
|
||||||
}
|
}
|
||||||
@@ -261,21 +259,22 @@ func (s AccessEntryService) RecordDecisions(
|
|||||||
return entries, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccessEntryService) FlagEntry(
|
func (s *Service) FlagEntry(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req FlagAccessEntryRequest,
|
scope coredata.Scoper,
|
||||||
) (*coredata.AccessEntry, error) {
|
req FlagAccessReviewEntryRequest,
|
||||||
entry := &coredata.AccessEntry{}
|
) (*coredata.AccessReviewEntry, error) {
|
||||||
|
entry := &coredata.AccessReviewEntry{}
|
||||||
|
|
||||||
err := s.pg.WithTx(
|
err := s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Tx) error {
|
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)
|
return fmt.Errorf("cannot load access entry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
campaign := &coredata.AccessReviewCampaign{}
|
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)
|
return fmt.Errorf("cannot load campaign: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +286,7 @@ func (s AccessEntryService) FlagEntry(
|
|||||||
|
|
||||||
entry.Flags = req.Flags
|
entry.Flags = req.Flags
|
||||||
if entry.Flags == nil {
|
if entry.Flags == nil {
|
||||||
entry.Flags = []coredata.AccessEntryFlag{}
|
entry.Flags = []coredata.AccessReviewEntryFlag{}
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.FlagReasons = req.FlagReasons
|
entry.FlagReasons = req.FlagReasons
|
||||||
@@ -297,28 +296,29 @@ func (s AccessEntryService) FlagEntry(
|
|||||||
|
|
||||||
entry.UpdatedAt = now
|
entry.UpdatedAt = now
|
||||||
|
|
||||||
return entry.UpdateFlags(ctx, conn, s.scope)
|
return entry.UpdateFlags(ctx, conn, scope)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot flag access entry: %w", err)
|
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,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
campaignID gid.GID,
|
campaignID gid.GID,
|
||||||
cursor *page.Cursor[coredata.AccessEntryOrderField],
|
cursor *page.Cursor[coredata.AccessReviewEntryOrderField],
|
||||||
filter *coredata.AccessEntryFilter,
|
filter *coredata.AccessReviewEntryFilter,
|
||||||
) (*page.Page[*coredata.AccessEntry, coredata.AccessEntryOrderField], error) {
|
) (*page.Page[*coredata.AccessReviewEntry, coredata.AccessReviewEntryOrderField], error) {
|
||||||
var entries coredata.AccessEntries
|
var entries coredata.AccessReviewEntries
|
||||||
|
|
||||||
err := s.pg.WithConn(
|
err := s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
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 {
|
if err != nil {
|
||||||
@@ -328,19 +328,20 @@ func (s AccessEntryService) ListForCampaignID(
|
|||||||
return page.NewPage(entries, cursor), nil
|
return page.NewPage(entries, cursor), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccessEntryService) ListForCampaignIDAndSourceID(
|
func (s *Service) ListEntriesForCampaignIDAndSourceID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
campaignID gid.GID,
|
campaignID gid.GID,
|
||||||
sourceID gid.GID,
|
sourceID gid.GID,
|
||||||
cursor *page.Cursor[coredata.AccessEntryOrderField],
|
cursor *page.Cursor[coredata.AccessReviewEntryOrderField],
|
||||||
filter *coredata.AccessEntryFilter,
|
filter *coredata.AccessReviewEntryFilter,
|
||||||
) (*page.Page[*coredata.AccessEntry, coredata.AccessEntryOrderField], error) {
|
) (*page.Page[*coredata.AccessReviewEntry, coredata.AccessReviewEntryOrderField], error) {
|
||||||
var entries coredata.AccessEntries
|
var entries coredata.AccessReviewEntries
|
||||||
|
|
||||||
err := s.pg.WithConn(
|
err := s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
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 {
|
if err != nil {
|
||||||
@@ -350,19 +351,20 @@ func (s AccessEntryService) ListForCampaignIDAndSourceID(
|
|||||||
return page.NewPage(entries, cursor), nil
|
return page.NewPage(entries, cursor), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccessEntryService) CountForCampaignID(
|
func (s *Service) CountEntriesForCampaignID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
campaignID gid.GID,
|
campaignID gid.GID,
|
||||||
filter *coredata.AccessEntryFilter,
|
filter *coredata.AccessReviewEntryFilter,
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
var count int
|
var count int
|
||||||
|
|
||||||
err := s.pg.WithConn(
|
err := s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) (err error) {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot count access entries by campaign: %w", err)
|
return fmt.Errorf("cannot count access entries by campaign: %w", err)
|
||||||
}
|
}
|
||||||
@@ -377,20 +379,21 @@ func (s AccessEntryService) CountForCampaignID(
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccessEntryService) CountForCampaignIDAndSourceID(
|
func (s *Service) CountEntriesForCampaignIDAndSourceID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
campaignID gid.GID,
|
campaignID gid.GID,
|
||||||
sourceID gid.GID,
|
sourceID gid.GID,
|
||||||
filter *coredata.AccessEntryFilter,
|
filter *coredata.AccessReviewEntryFilter,
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
var count int
|
var count int
|
||||||
|
|
||||||
err := s.pg.WithConn(
|
err := s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) (err error) {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot count access entries by campaign and source: %w", err)
|
return fmt.Errorf("cannot count access entries by campaign and source: %w", err)
|
||||||
}
|
}
|
||||||
@@ -405,8 +408,9 @@ func (s AccessEntryService) CountForCampaignIDAndSourceID(
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccessEntryService) CountPendingForCampaignID(
|
func (s *Service) CountPendingEntriesForCampaignID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
campaignID gid.GID,
|
campaignID gid.GID,
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
var count int
|
var count int
|
||||||
@@ -414,9 +418,9 @@ func (s AccessEntryService) CountPendingForCampaignID(
|
|||||||
err := s.pg.WithConn(
|
err := s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) (err error) {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot count pending access entries: %w", err)
|
return fmt.Errorf("cannot count pending access entries: %w", err)
|
||||||
}
|
}
|
||||||
@@ -431,16 +435,17 @@ func (s AccessEntryService) CountPendingForCampaignID(
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccessEntryService) DecisionHistory(
|
func (s *Service) EntryDecisionHistory(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
entryID gid.GID,
|
entryID gid.GID,
|
||||||
) (coredata.AccessEntryDecisionHistories, error) {
|
) (coredata.AccessReviewEntryDecisionHistories, error) {
|
||||||
var histories coredata.AccessEntryDecisionHistories
|
var histories coredata.AccessReviewEntryDecisionHistories
|
||||||
|
|
||||||
err := s.pg.WithConn(
|
err := s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
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 {
|
if err != nil {
|
||||||
@@ -450,16 +455,17 @@ func (s AccessEntryService) DecisionHistory(
|
|||||||
return histories, nil
|
return histories, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccessEntryService) Statistics(
|
func (s *Service) CampaignStatistics(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
campaignID gid.GID,
|
campaignID gid.GID,
|
||||||
) (*coredata.AccessEntryStatistics, error) {
|
) (*coredata.AccessReviewStatistics, error) {
|
||||||
stats := &coredata.AccessEntryStatistics{}
|
stats := &coredata.AccessReviewStatistics{}
|
||||||
|
|
||||||
err := s.pg.WithConn(
|
err := s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
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 {
|
if err != nil {
|
||||||
@@ -469,17 +475,18 @@ func (s AccessEntryService) Statistics(
|
|||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccessEntryService) StatisticsForSource(
|
func (s *Service) CampaignSourceStatistics(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
campaignID gid.GID,
|
campaignID gid.GID,
|
||||||
sourceID gid.GID,
|
sourceID gid.GID,
|
||||||
) (*coredata.AccessEntryStatistics, error) {
|
) (*coredata.AccessReviewStatistics, error) {
|
||||||
stats := &coredata.AccessEntryStatistics{}
|
stats := &coredata.AccessReviewStatistics{}
|
||||||
|
|
||||||
err := s.pg.WithConn(
|
err := s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
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 {
|
if err != nil {
|
||||||
60
pkg/accessreview/policies.go
Normal file
60
pkg/accessreview/policies.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -22,66 +22,42 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.gearno.de/kit/log"
|
|
||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
"go.probo.inc/probo/pkg/accessreview/drivers"
|
"go.probo.inc/probo/pkg/accessreview/drivers"
|
||||||
"go.probo.inc/probo/pkg/connector"
|
"go.probo.inc/probo/pkg/connector"
|
||||||
"go.probo.inc/probo/pkg/connector/provider"
|
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/crypto/cipher"
|
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReviewEngine contains the stateless core logic for access review campaigns:
|
// FetchSource pulls accounts from a single campaign source snapshot and upserts
|
||||||
// snapshot and source data collection.
|
// access entries against that snapshot.
|
||||||
type ReviewEngine struct {
|
func (s *Service) FetchSource(
|
||||||
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(
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
campaign *coredata.AccessReviewCampaign,
|
campaign *coredata.AccessReviewCampaign,
|
||||||
sourceID gid.GID,
|
campaignSource *coredata.AccessReviewCampaignSource,
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
fetchedCount := 0
|
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
|
// Resolve the driver and load baseline data outside the write transaction
|
||||||
// so that external HTTP calls do not hold a database connection.
|
// so that external HTTP calls do not hold a database connection.
|
||||||
var (
|
var (
|
||||||
source *coredata.AccessSource
|
source *coredata.AccessReviewSource
|
||||||
driver drivers.Driver
|
driver drivers.Driver
|
||||||
baseline []coredata.BaselineAccountEntry
|
baseline []coredata.BaselineAccountEntry
|
||||||
)
|
)
|
||||||
|
|
||||||
err := e.pg.WithTx(
|
err := s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, tx pg.Tx) error {
|
func(ctx context.Context, tx pg.Tx) error {
|
||||||
source = &coredata.AccessSource{}
|
source = &coredata.AccessReviewSource{}
|
||||||
if err := source.LoadByID(ctx, tx, e.scope, sourceID); err != nil {
|
if err := source.LoadByID(ctx, tx, scope, sourceID); err != nil {
|
||||||
return fmt.Errorf("cannot load access source %s: %w", sourceID, err)
|
return fmt.Errorf("cannot load access source %s: %w", sourceID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,20 +67,20 @@ func (e *ReviewEngine) FetchSource(
|
|||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
driver, err = e.resolveDriver(ctx, tx, source)
|
driver, err = s.resolveDriver(ctx, tx, scope, source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot resolve driver for source %s: %w", source.Name, err)
|
return fmt.Errorf("cannot resolve driver for source %s: %w", source.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
lastCompletedCampaign := &coredata.AccessReviewCampaign{}
|
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) {
|
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
return fmt.Errorf("cannot load last completed campaign: %w", err)
|
return fmt.Errorf("cannot load last completed campaign: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot load baseline entries by source: %w", err)
|
return fmt.Errorf("cannot load baseline entries by source: %w", err)
|
||||||
}
|
}
|
||||||
@@ -133,7 +109,7 @@ func (e *ReviewEngine) FetchSource(
|
|||||||
|
|
||||||
fetchedCount = len(accounts)
|
fetchedCount = len(accounts)
|
||||||
|
|
||||||
err = e.pg.WithTx(
|
err = s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Tx) error {
|
func(ctx context.Context, conn pg.Tx) error {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -143,38 +119,38 @@ func (e *ReviewEngine) FetchSource(
|
|||||||
accountKey := normalizeAccountKey(account.Email, account.ExternalID)
|
accountKey := normalizeAccountKey(account.Email, account.ExternalID)
|
||||||
seenAccountKeys[accountKey] = struct{}{}
|
seenAccountKeys[accountKey] = struct{}{}
|
||||||
|
|
||||||
incrementalTag := coredata.AccessEntryIncrementalTagNew
|
incrementalTag := coredata.AccessReviewEntryIncrementalTagNew
|
||||||
if _, ok := previousByAccountKey[accountKey]; ok {
|
if _, ok := previousByAccountKey[accountKey]; ok {
|
||||||
incrementalTag = coredata.AccessEntryIncrementalTagUnchanged
|
incrementalTag = coredata.AccessReviewEntryIncrementalTagUnchanged
|
||||||
}
|
}
|
||||||
|
|
||||||
entry := &coredata.AccessEntry{
|
entry := &coredata.AccessReviewEntry{
|
||||||
ID: gid.New(e.scope.GetTenantID(), coredata.AccessEntryEntityType),
|
ID: gid.New(scope.GetTenantID(), coredata.AccessReviewEntryEntityType),
|
||||||
OrganizationID: campaign.OrganizationID,
|
OrganizationID: campaign.OrganizationID,
|
||||||
AccessReviewCampaignID: campaign.ID,
|
AccessReviewCampaignID: campaign.ID,
|
||||||
AccessSourceID: sourceID,
|
AccessReviewCampaignSourceID: campaignSource.ID,
|
||||||
Email: account.Email,
|
Email: account.Email,
|
||||||
FullName: account.FullName,
|
FullName: account.FullName,
|
||||||
Role: account.Role,
|
Role: account.Role,
|
||||||
JobTitle: account.JobTitle,
|
JobTitle: account.JobTitle,
|
||||||
IsAdmin: account.IsAdmin,
|
IsAdmin: account.IsAdmin,
|
||||||
MFAStatus: account.MFAStatus,
|
MFAStatus: account.MFAStatus,
|
||||||
AuthMethod: account.AuthMethod,
|
AuthMethod: account.AuthMethod,
|
||||||
AccountType: account.AccountType,
|
AccountType: account.AccountType,
|
||||||
Active: account.Active,
|
Active: account.Active,
|
||||||
LastLogin: account.LastLogin,
|
LastLogin: account.LastLogin,
|
||||||
AccountCreatedAt: account.CreatedAt,
|
AccountCreatedAt: account.CreatedAt,
|
||||||
ExternalID: account.ExternalID,
|
ExternalID: account.ExternalID,
|
||||||
AccountKey: accountKey,
|
AccountKey: accountKey,
|
||||||
IncrementalTag: incrementalTag,
|
IncrementalTag: incrementalTag,
|
||||||
Flags: []coredata.AccessEntryFlag{},
|
Flags: []coredata.AccessReviewEntryFlag{},
|
||||||
FlagReasons: []string{},
|
FlagReasons: []string{},
|
||||||
Decision: coredata.AccessEntryDecisionPending,
|
Decision: coredata.AccessReviewEntryDecisionPending,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: 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)
|
return fmt.Errorf("cannot upsert access entry: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,26 +162,26 @@ func (e *ReviewEngine) FetchSource(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
entry := &coredata.AccessEntry{
|
entry := &coredata.AccessReviewEntry{
|
||||||
ID: gid.New(e.scope.GetTenantID(), coredata.AccessEntryEntityType),
|
ID: gid.New(scope.GetTenantID(), coredata.AccessReviewEntryEntityType),
|
||||||
OrganizationID: campaign.OrganizationID,
|
OrganizationID: campaign.OrganizationID,
|
||||||
AccessReviewCampaignID: campaign.ID,
|
AccessReviewCampaignID: campaign.ID,
|
||||||
AccessSourceID: sourceID,
|
AccessReviewCampaignSourceID: campaignSource.ID,
|
||||||
Email: prev.Email,
|
Email: prev.Email,
|
||||||
FullName: prev.FullName,
|
FullName: prev.FullName,
|
||||||
AccountKey: accountKey,
|
AccountKey: accountKey,
|
||||||
IncrementalTag: coredata.AccessEntryIncrementalTagRemoved,
|
IncrementalTag: coredata.AccessReviewEntryIncrementalTagRemoved,
|
||||||
Flags: []coredata.AccessEntryFlag{},
|
Flags: []coredata.AccessReviewEntryFlag{},
|
||||||
FlagReasons: []string{},
|
FlagReasons: []string{},
|
||||||
Decision: coredata.AccessEntryDecisionPending,
|
Decision: coredata.AccessReviewEntryDecisionPending,
|
||||||
MFAStatus: coredata.MFAStatusUnknown,
|
MFAStatus: coredata.MFAStatusUnknown,
|
||||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: 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)
|
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
|
// oauthClient returns an HTTP client for an OAuth2 connection, using
|
||||||
// RefreshableClient when a refresh config is available for the provider.
|
// RefreshableClient when a refresh config is available for the provider.
|
||||||
func (e *ReviewEngine) oauthClient(
|
func (s *Service) oauthClient(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn *connector.OAuth2Connection,
|
conn *connector.OAuth2Connection,
|
||||||
provider coredata.ConnectorProvider,
|
provider coredata.ConnectorProvider,
|
||||||
) (*http.Client, error) {
|
) (*http.Client, error) {
|
||||||
if e.connectorRegistry != nil {
|
if s.connectorRegistry != nil {
|
||||||
refreshCfg := e.connectorRegistry.GetOAuth2RefreshConfig(string(provider))
|
refreshCfg := s.connectorRegistry.GetOAuth2RefreshConfig(string(provider))
|
||||||
if refreshCfg != nil {
|
if refreshCfg != nil {
|
||||||
return conn.RefreshableClient(ctx, *refreshCfg)
|
return conn.RefreshableClient(ctx, *refreshCfg)
|
||||||
}
|
}
|
||||||
@@ -252,23 +228,24 @@ func (e *ReviewEngine) oauthClient(
|
|||||||
// For OAuth2 connections it delegates to oauthClient so that token refresh
|
// For OAuth2 connections it delegates to oauthClient so that token refresh
|
||||||
// is handled transparently. For other connection types it falls back to
|
// is handled transparently. For other connection types it falls back to
|
||||||
// the standard Client method.
|
// the standard Client method.
|
||||||
func (e *ReviewEngine) connectorHTTPClient(
|
func (s *Service) connectorHTTPClient(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
dbConnector *coredata.Connector,
|
dbConnector *coredata.Connector,
|
||||||
) (*http.Client, error) {
|
) (*http.Client, error) {
|
||||||
if oauth2Conn, ok := dbConnector.Connection.(*connector.OAuth2Connection); ok {
|
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)
|
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).
|
// connector_id (null = built-in, set = connector-backed).
|
||||||
func (e *ReviewEngine) resolveDriver(
|
func (s *Service) resolveDriver(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tx pg.Tx,
|
tx pg.Tx,
|
||||||
source *coredata.AccessSource,
|
scope coredata.Scoper,
|
||||||
|
source *coredata.AccessReviewSource,
|
||||||
) (drivers.Driver, error) {
|
) (drivers.Driver, error) {
|
||||||
if source.ConnectorID == nil {
|
if source.ConnectorID == nil {
|
||||||
// CSV-backed source: use CSVDriver when csv_data is present
|
// CSV-backed source: use CSVDriver when csv_data is present
|
||||||
@@ -277,12 +254,12 @@ func (e *ReviewEngine) resolveDriver(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Built-in driver: default to ProboMemberships
|
// 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
|
// Connector-backed: look up the connector and resolve driver by provider
|
||||||
dbConnector := &coredata.Connector{}
|
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)
|
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
|
// Build an HTTP client. For OAuth2 connections, use RefreshableClient
|
||||||
// so that short-lived tokens are transparently refreshed.
|
// so that short-lived tokens are transparently refreshed.
|
||||||
httpClient, err := e.connectorHTTPClient(ctx, dbConnector)
|
httpClient, err := s.connectorHTTPClient(ctx, dbConnector)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot create HTTP client for %s connector: %w", dbConnector.Provider, err)
|
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, ok := dbConnector.Connection.(*connector.OAuth2Connection); ok {
|
||||||
if oauth2Conn.AccessToken != tokenBefore {
|
if oauth2Conn.AccessToken != tokenBefore {
|
||||||
dbConnector.UpdatedAt = time.Now()
|
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)
|
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 {
|
if !ok || reg.NewDriver == nil {
|
||||||
return nil, fmt.Errorf("cannot resolve driver: unsupported provider %q", dbConnector.Provider)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ type (
|
|||||||
providerRegistry *provider.Registry
|
providerRegistry *provider.Registry
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
|
|
||||||
fetchWorker *worker.Worker[coredata.AccessReviewCampaignSourceFetch]
|
fetchWorker *worker.Worker[coredata.AccessReviewCampaignSourceFetchAttempt]
|
||||||
sourceNameWorker *worker.Worker[coredata.AccessSource]
|
sourceNameWorker *worker.Worker[coredata.AccessReviewSource]
|
||||||
}
|
}
|
||||||
|
|
||||||
Option func(*options)
|
Option func(*options)
|
||||||
@@ -102,39 +102,6 @@ func NewService(
|
|||||||
return s
|
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.
|
// ResolveEntryOrganizationID resolves the organization ID for an access entry.
|
||||||
// This is unscoped because it is used by resolvers before authorization to
|
// This is unscoped because it is used by resolvers before authorization to
|
||||||
// find the organization from an entry ID.
|
// 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 {
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
entry := &coredata.AccessEntry{}
|
entry := &coredata.AccessReviewEntry{}
|
||||||
|
|
||||||
organizationID, err = entry.LoadOrganizationID(ctx, conn, entryID)
|
organizationID, err = entry.LoadOrganizationID(ctx, conn, entryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func NewSourceNameWorker(
|
|||||||
providerRegistry *provider.Registry,
|
providerRegistry *provider.Registry,
|
||||||
logger *log.Logger,
|
logger *log.Logger,
|
||||||
opts ...worker.Option,
|
opts ...worker.Option,
|
||||||
) *worker.Worker[coredata.AccessSource] {
|
) *worker.Worker[coredata.AccessReviewSource] {
|
||||||
h := &sourceNameHandler{
|
h := &sourceNameHandler{
|
||||||
pg: pgClient,
|
pg: pgClient,
|
||||||
encryptionKey: encryptionKey,
|
encryptionKey: encryptionKey,
|
||||||
@@ -70,8 +70,8 @@ func NewSourceNameWorker(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *sourceNameHandler) Claim(ctx context.Context) (coredata.AccessSource, error) {
|
func (h *sourceNameHandler) Claim(ctx context.Context) (coredata.AccessReviewSource, error) {
|
||||||
var source coredata.AccessSource
|
var source coredata.AccessReviewSource
|
||||||
|
|
||||||
err := h.pg.WithTx(
|
err := h.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -80,17 +80,17 @@ func (h *sourceNameHandler) Claim(ctx context.Context) (coredata.AccessSource, e
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, coredata.ErrNoAccessSourceNameSyncAvailable) {
|
if errors.Is(err, coredata.ErrNoAccessReviewSourceNameSyncAvailable) {
|
||||||
return coredata.AccessSource{}, worker.ErrNoTask
|
return coredata.AccessReviewSource{}, worker.ErrNoTask
|
||||||
}
|
}
|
||||||
|
|
||||||
return coredata.AccessSource{}, err
|
return coredata.AccessReviewSource{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return source, nil
|
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(
|
h.logger.InfoCtx(
|
||||||
ctx,
|
ctx,
|
||||||
"syncing source name",
|
"syncing source name",
|
||||||
@@ -206,7 +206,7 @@ func (h *sourceNameHandler) Process(ctx context.Context, source coredata.AccessS
|
|||||||
|
|
||||||
func (h *sourceNameHandler) markNameSynced(
|
func (h *sourceNameHandler) markNameSynced(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
source *coredata.AccessSource,
|
source *coredata.AccessReviewSource,
|
||||||
) error {
|
) error {
|
||||||
return h.pg.WithTx(
|
return h.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ import (
|
|||||||
|
|
||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
"go.probo.inc/probo/pkg/connector"
|
"go.probo.inc/probo/pkg/connector"
|
||||||
"go.probo.inc/probo/pkg/connector/provider"
|
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/crypto/cipher"
|
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/page"
|
"go.probo.inc/probo/pkg/page"
|
||||||
"go.probo.inc/probo/pkg/validator"
|
"go.probo.inc/probo/pkg/validator"
|
||||||
@@ -35,76 +33,69 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
AccessSourceService struct {
|
CreateAccessReviewSourceRequest struct {
|
||||||
pg *pg.Client
|
|
||||||
scope coredata.Scoper
|
|
||||||
encryptionKey cipher.EncryptionKey
|
|
||||||
connectorRegistry *connector.ConnectorRegistry
|
|
||||||
providerRegistry *provider.Registry
|
|
||||||
}
|
|
||||||
|
|
||||||
CreateAccessSourceRequest struct {
|
|
||||||
OrganizationID gid.GID
|
OrganizationID gid.GID
|
||||||
ConnectorID *gid.GID
|
ConnectorID *gid.GID
|
||||||
Name string
|
Name string
|
||||||
Category coredata.AccessSourceCategory
|
Category coredata.AccessReviewSourceCategory
|
||||||
CsvData *string
|
CsvData *string
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateAccessSourceRequest struct {
|
UpdateAccessReviewSourceRequest struct {
|
||||||
AccessSourceID gid.GID
|
AccessReviewSourceID gid.GID
|
||||||
Name *string
|
Name *string
|
||||||
Category *coredata.AccessSourceCategory
|
Category *coredata.AccessReviewSourceCategory
|
||||||
ConnectorID **gid.GID
|
ConnectorID **gid.GID
|
||||||
CsvData **string
|
CsvData **string
|
||||||
}
|
}
|
||||||
|
|
||||||
ConfigureAccessSourceRequest struct {
|
ConfigureAccessReviewSourceRequest struct {
|
||||||
AccessSourceID gid.GID
|
AccessReviewSourceID gid.GID
|
||||||
OrganizationSlug string
|
OrganizationSlug string
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *CreateAccessSourceRequest) Validate() error {
|
func (r *CreateAccessReviewSourceRequest) Validate() error {
|
||||||
v := validator.New()
|
v := validator.New()
|
||||||
|
|
||||||
v.Check(r.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
v.Check(r.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||||
v.Check(r.Name, "name", validator.SafeTextNoNewLine(NameMaxLength))
|
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()
|
return v.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ConfigureAccessSourceRequest) Validate() error {
|
func (r *ConfigureAccessReviewSourceRequest) Validate() error {
|
||||||
v := validator.New()
|
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())
|
v.Check(r.OrganizationSlug, "organization_slug", validator.Required())
|
||||||
|
|
||||||
return v.Error()
|
return v.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UpdateAccessSourceRequest) Validate() error {
|
func (r *UpdateAccessReviewSourceRequest) Validate() error {
|
||||||
v := validator.New()
|
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.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()
|
return v.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccessSourceService) Create(
|
func (s *Service) CreateSource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req CreateAccessSourceRequest,
|
scope coredata.Scoper,
|
||||||
) (*coredata.AccessSource, error) {
|
req CreateAccessReviewSourceRequest,
|
||||||
|
) (*coredata.AccessReviewSource, error) {
|
||||||
if err := req.Validate(); err != nil {
|
if err := req.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
source := &coredata.AccessSource{
|
source := &coredata.AccessReviewSource{
|
||||||
ID: gid.New(s.scope.GetTenantID(), coredata.AccessSourceEntityType),
|
ID: gid.New(scope.GetTenantID(), coredata.AccessReviewSourceEntityType),
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
ConnectorID: req.ConnectorID,
|
ConnectorID: req.ConnectorID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
@@ -120,12 +111,12 @@ func (s AccessSourceService) Create(
|
|||||||
// Validate connector exists if provided
|
// Validate connector exists if provided
|
||||||
if req.ConnectorID != nil {
|
if req.ConnectorID != nil {
|
||||||
connector := &coredata.Connector{}
|
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)
|
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)
|
return fmt.Errorf("cannot insert access source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,16 +130,17 @@ func (s AccessSourceService) Create(
|
|||||||
return source, nil
|
return source, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccessSourceService) Get(
|
func (s *Service) GetSource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
accessSourceID gid.GID,
|
accessSourceID gid.GID,
|
||||||
) (*coredata.AccessSource, error) {
|
) (*coredata.AccessReviewSource, error) {
|
||||||
source := &coredata.AccessSource{}
|
source := &coredata.AccessReviewSource{}
|
||||||
|
|
||||||
err := s.pg.WithConn(
|
err := s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
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 {
|
if err != nil {
|
||||||
@@ -158,20 +150,21 @@ func (s AccessSourceService) Get(
|
|||||||
return source, nil
|
return source, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccessSourceService) Update(
|
func (s *Service) UpdateSource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req UpdateAccessSourceRequest,
|
scope coredata.Scoper,
|
||||||
) (*coredata.AccessSource, error) {
|
req UpdateAccessReviewSourceRequest,
|
||||||
|
) (*coredata.AccessReviewSource, error) {
|
||||||
if err := req.Validate(); err != nil {
|
if err := req.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
source := &coredata.AccessSource{}
|
source := &coredata.AccessReviewSource{}
|
||||||
|
|
||||||
err := s.pg.WithTx(
|
err := s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Tx) error {
|
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)
|
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 {
|
||||||
if *req.ConnectorID != nil {
|
if *req.ConnectorID != nil {
|
||||||
connector := &coredata.Connector{}
|
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)
|
return fmt.Errorf("cannot load connector: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +193,7 @@ func (s AccessSourceService) Update(
|
|||||||
|
|
||||||
source.UpdatedAt = time.Now()
|
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)
|
return fmt.Errorf("cannot update access source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,20 +207,21 @@ func (s AccessSourceService) Update(
|
|||||||
return source, nil
|
return source, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccessSourceService) Delete(
|
func (s *Service) DeleteSource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
accessSourceID gid.GID,
|
accessSourceID gid.GID,
|
||||||
) error {
|
) error {
|
||||||
source := &coredata.AccessSource{}
|
source := &coredata.AccessReviewSource{}
|
||||||
|
|
||||||
return s.pg.WithTx(
|
return s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Tx) error {
|
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)
|
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)
|
return fmt.Errorf("cannot delete access source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,9 +233,9 @@ func (s AccessSourceService) Delete(
|
|||||||
return nil
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot count access sources for connector: %w", err)
|
return fmt.Errorf("cannot count access sources for connector: %w", err)
|
||||||
}
|
}
|
||||||
@@ -252,7 +246,7 @@ func (s AccessSourceService) Delete(
|
|||||||
|
|
||||||
bridges := &coredata.SCIMBridges{}
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot count scim bridges for connector: %w", err)
|
return fmt.Errorf("cannot count scim bridges for connector: %w", err)
|
||||||
}
|
}
|
||||||
@@ -272,7 +266,7 @@ func (s AccessSourceService) Delete(
|
|||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Tx) error {
|
func(ctx context.Context, conn pg.Tx) error {
|
||||||
cnnctr := &coredata.Connector{ID: *source.ConnectorID}
|
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)
|
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,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
cursor *page.Cursor[coredata.AccessSourceOrderField],
|
cursor *page.Cursor[coredata.AccessReviewSourceOrderField],
|
||||||
) (*page.Page[*coredata.AccessSource, coredata.AccessSourceOrderField], error) {
|
) (*page.Page[*coredata.AccessReviewSource, coredata.AccessReviewSourceOrderField], error) {
|
||||||
var sources coredata.AccessSources
|
var sources coredata.AccessReviewSources
|
||||||
|
|
||||||
err := s.pg.WithConn(
|
err := s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
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 {
|
if err != nil {
|
||||||
@@ -307,8 +302,9 @@ func (s AccessSourceService) ListForOrganizationID(
|
|||||||
return page.NewPage(sources, cursor), nil
|
return page.NewPage(sources, cursor), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccessSourceService) CountForOrganizationID(
|
func (s *Service) CountSourcesForOrganizationID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
var count int
|
var count int
|
||||||
@@ -316,8 +312,8 @@ func (s AccessSourceService) CountForOrganizationID(
|
|||||||
err := s.pg.WithConn(
|
err := s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) (err error) {
|
func(ctx context.Context, conn pg.Querier) (err error) {
|
||||||
sources := coredata.AccessSources{}
|
sources := coredata.AccessReviewSources{}
|
||||||
count, err = sources.CountByOrganizationID(ctx, conn, s.scope, organizationID)
|
count, err = sources.CountByOrganizationID(ctx, conn, scope, organizationID)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
@@ -329,30 +325,12 @@ func (s AccessSourceService) CountForOrganizationID(
|
|||||||
return count, nil
|
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
|
// ConnectorHTTPClient loads a connector by ID with decrypted credentials
|
||||||
// and returns an HTTP client with token refresh support. If the token was
|
// and returns an HTTP client with token refresh support. If the token was
|
||||||
// refreshed during client creation, the updated credentials are persisted.
|
// refreshed during client creation, the updated credentials are persisted.
|
||||||
func (s AccessSourceService) ConnectorHTTPClient(
|
func (s *Service) ConnectorHTTPClient(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
connectorID gid.GID,
|
connectorID gid.GID,
|
||||||
) (*http.Client, *coredata.Connector, error) {
|
) (*http.Client, *coredata.Connector, error) {
|
||||||
var dbConnector coredata.Connector
|
var dbConnector coredata.Connector
|
||||||
@@ -360,7 +338,7 @@ func (s AccessSourceService) ConnectorHTTPClient(
|
|||||||
err := s.pg.WithConn(
|
err := s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
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)
|
return fmt.Errorf("cannot load connector: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +386,7 @@ func (s AccessSourceService) ConnectorHTTPClient(
|
|||||||
if err := s.pg.WithTx(
|
if err := s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, tx pg.Tx) error {
|
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 {
|
); err != nil {
|
||||||
return nil, nil, fmt.Errorf("cannot persist refreshed token: %w", err)
|
return nil, nil, fmt.Errorf("cannot persist refreshed token: %w", err)
|
||||||
@@ -418,20 +396,21 @@ func (s AccessSourceService) ConnectorHTTPClient(
|
|||||||
return httpClient, &dbConnector, nil
|
return httpClient, &dbConnector, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AccessSourceService) ConfigureAccessSource(
|
func (s *Service) ConfigureAccessReviewSource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req ConfigureAccessSourceRequest,
|
scope coredata.Scoper,
|
||||||
) (*coredata.AccessSource, error) {
|
req ConfigureAccessReviewSourceRequest,
|
||||||
|
) (*coredata.AccessReviewSource, error) {
|
||||||
if err := req.Validate(); err != nil {
|
if err := req.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
source := &coredata.AccessSource{}
|
source := &coredata.AccessReviewSource{}
|
||||||
|
|
||||||
err := s.pg.WithTx(
|
err := s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Tx) error {
|
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)
|
return fmt.Errorf("cannot load access source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +419,7 @@ func (s AccessSourceService) ConfigureAccessSource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
dbConnector := &coredata.Connector{}
|
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)
|
return fmt.Errorf("cannot load connector: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,7 +434,7 @@ func (s AccessSourceService) ConfigureAccessSource(
|
|||||||
|
|
||||||
dbConnector.UpdatedAt = time.Now()
|
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)
|
return fmt.Errorf("cannot update connector: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +27,11 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/gid"
|
"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 {
|
type sourceFetchHandler struct {
|
||||||
svc *Service
|
svc *Service
|
||||||
pg *pg.Client
|
pg *pg.Client
|
||||||
@@ -39,7 +44,7 @@ func NewSourceFetchWorker(
|
|||||||
pgClient *pg.Client,
|
pgClient *pg.Client,
|
||||||
logger *log.Logger,
|
logger *log.Logger,
|
||||||
opts ...worker.Option,
|
opts ...worker.Option,
|
||||||
) *worker.Worker[coredata.AccessReviewCampaignSourceFetch] {
|
) *worker.Worker[coredata.AccessReviewCampaignSourceFetchAttempt] {
|
||||||
h := &sourceFetchHandler{
|
h := &sourceFetchHandler{
|
||||||
svc: svc,
|
svc: svc,
|
||||||
pg: pgClient,
|
pg: pgClient,
|
||||||
@@ -55,44 +60,43 @@ func NewSourceFetchWorker(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *sourceFetchHandler) Claim(ctx context.Context) (coredata.AccessReviewCampaignSourceFetch, error) {
|
func (h *sourceFetchHandler) Claim(ctx context.Context) (coredata.AccessReviewCampaignSourceFetchAttempt, error) {
|
||||||
var sourceFetch coredata.AccessReviewCampaignSourceFetch
|
var attempt coredata.AccessReviewCampaignSourceFetchAttempt
|
||||||
|
|
||||||
if err := h.pg.WithTx(
|
if err := h.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, tx pg.Tx) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
sourceFetch.Status = coredata.AccessReviewCampaignSourceFetchStatusFetching
|
attempt.Status = coredata.AccessReviewCampaignSourceFetchStatusFetching
|
||||||
sourceFetch.AttemptCount++
|
attempt.Error = nil
|
||||||
sourceFetch.LastError = nil
|
attempt.StartedAt = &now
|
||||||
sourceFetch.StartedAt = new(now)
|
attempt.CompletedAt = nil
|
||||||
sourceFetch.CompletedAt = nil
|
attempt.UpdatedAt = now
|
||||||
sourceFetch.UpdatedAt = now
|
|
||||||
|
|
||||||
scope := coredata.NewScope(sourceFetch.TenantID)
|
scope := coredata.NewScope(attempt.TenantID)
|
||||||
if err := sourceFetch.Update(ctx, tx, scope); err != nil {
|
if err := attempt.Update(ctx, tx, scope); err != nil {
|
||||||
return fmt.Errorf("cannot update source fetch status: %w", err)
|
return fmt.Errorf("cannot update fetch attempt status: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
if errors.Is(err, coredata.ErrNoAccessReviewCampaignSourceFetchAvailable) {
|
if errors.Is(err, coredata.ErrNoAccessReviewCampaignSourceFetchAttemptAvailable) {
|
||||||
return coredata.AccessReviewCampaignSourceFetch{}, worker.ErrNoTask
|
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 {
|
func (h *sourceFetchHandler) Process(ctx context.Context, attempt coredata.AccessReviewCampaignSourceFetchAttempt) error {
|
||||||
return h.handle(ctx, &sourceFetch)
|
return h.handle(ctx, &attempt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *sourceFetchHandler) RecoverStale(ctx context.Context) error {
|
func (h *sourceFetchHandler) RecoverStale(ctx context.Context) error {
|
||||||
@@ -102,18 +106,18 @@ func (h *sourceFetchHandler) RecoverStale(ctx context.Context) error {
|
|||||||
return h.pg.WithTx(
|
return h.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, tx pg.Tx) error {
|
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 {
|
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 {
|
if count > 0 {
|
||||||
h.logger.InfoCtx(
|
h.logger.InfoCtx(
|
||||||
ctx,
|
ctx,
|
||||||
"recovered stale source fetches",
|
"recovered stale fetch attempts",
|
||||||
log.Int64("count", count),
|
log.Int("count", count),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,101 +128,123 @@ func (h *sourceFetchHandler) RecoverStale(ctx context.Context) error {
|
|||||||
|
|
||||||
func (h *sourceFetchHandler) handle(
|
func (h *sourceFetchHandler) handle(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sourceFetch *coredata.AccessReviewCampaignSourceFetch,
|
attempt *coredata.AccessReviewCampaignSourceFetchAttempt,
|
||||||
) error {
|
) error {
|
||||||
scope := coredata.NewScope(sourceFetch.TenantID)
|
scope := coredata.NewScope(attempt.TenantID)
|
||||||
|
|
||||||
campaign, err := h.svc.Campaigns(scope).Get(ctx, sourceFetch.AccessReviewCampaignID)
|
campaignSource := &coredata.AccessReviewCampaignSource{}
|
||||||
if err != nil {
|
if err := h.loadCampaignSource(ctx, scope, attempt.AccessReviewCampaignSourceID, campaignSource); err != nil {
|
||||||
commitErr := h.commitFailedSourceFetch(
|
commitErr := h.commitFailedSourceFetch(ctx, attempt, fmt.Errorf("cannot load campaign source: %w", err))
|
||||||
ctx,
|
|
||||||
sourceFetch,
|
|
||||||
fmt.Errorf("cannot load campaign: %w", err),
|
|
||||||
)
|
|
||||||
if commitErr != nil {
|
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)
|
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 {
|
if err != nil {
|
||||||
commitErr := h.commitFailedSourceFetch(ctx, sourceFetch, err)
|
if commitErr := h.commitFailedSourceFetch(ctx, attempt, err); commitErr != nil {
|
||||||
if commitErr != nil {
|
return fmt.Errorf("cannot fetch source: %w, and cannot commit failed fetch attempt: %w", err, commitErr)
|
||||||
return fmt.Errorf("cannot fetch source: %w, and cannot commit failed source fetch: %w", err, commitErr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if finalizeErr := h.finalizeCampaignFetchLifecycle(ctx, sourceFetch.TenantID, sourceFetch.AccessReviewCampaignID); finalizeErr != nil {
|
if finalizeErr := h.finalizeCampaignFetchLifecycle(ctx, attempt.TenantID, campaignSource.AccessReviewCampaignID); finalizeErr != nil {
|
||||||
return fmt.Errorf("cannot finalize campaign after failed source fetch: %w", finalizeErr)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.commitSuccessfulSourceFetch(ctx, sourceFetch, count); err != nil {
|
if err := h.commitSuccessfulSourceFetch(ctx, attempt, count); err != nil {
|
||||||
return fmt.Errorf("cannot commit successful source fetch: %w", err)
|
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 fmt.Errorf("cannot finalize campaign fetch lifecycle: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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(
|
func (h *sourceFetchHandler) commitFailedSourceFetch(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sourceFetch *coredata.AccessReviewCampaignSourceFetch,
|
attempt *coredata.AccessReviewCampaignSourceFetchAttempt,
|
||||||
failureErr error,
|
failureErr error,
|
||||||
) error {
|
) error {
|
||||||
var (
|
h.logger.WarnCtx(
|
||||||
now = time.Now()
|
ctx,
|
||||||
errMsg = failureErr.Error()
|
"source fetch failed but campaign can continue",
|
||||||
scope = coredata.NewScopeFromObjectID(sourceFetch.AccessReviewCampaignID)
|
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
|
var (
|
||||||
sourceFetch.LastError = &errMsg
|
now = time.Now()
|
||||||
sourceFetch.CompletedAt = new(now)
|
errMsg = sourceFetchFailureMessage
|
||||||
sourceFetch.UpdatedAt = now
|
scope = coredata.NewScope(attempt.TenantID)
|
||||||
|
)
|
||||||
|
|
||||||
|
attempt.Status = coredata.AccessReviewCampaignSourceFetchStatusFailed
|
||||||
|
attempt.Error = &errMsg
|
||||||
|
attempt.CompletedAt = &now
|
||||||
|
attempt.UpdatedAt = now
|
||||||
|
|
||||||
return h.pg.WithTx(
|
return h.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, tx pg.Tx) error {
|
func(ctx context.Context, tx pg.Tx) error {
|
||||||
return sourceFetch.Update(ctx, tx, scope)
|
return attempt.Update(ctx, tx, scope)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *sourceFetchHandler) commitSuccessfulSourceFetch(
|
func (h *sourceFetchHandler) commitSuccessfulSourceFetch(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sourceFetch *coredata.AccessReviewCampaignSourceFetch,
|
attempt *coredata.AccessReviewCampaignSourceFetchAttempt,
|
||||||
fetchedAccountsCount int,
|
fetchedAccountsCount int,
|
||||||
) error {
|
) error {
|
||||||
var (
|
var (
|
||||||
now = time.Now()
|
now = time.Now()
|
||||||
scope = coredata.NewScopeFromObjectID(sourceFetch.AccessReviewCampaignID)
|
scope = coredata.NewScope(attempt.TenantID)
|
||||||
)
|
)
|
||||||
|
|
||||||
sourceFetch.Status = coredata.AccessReviewCampaignSourceFetchStatusSuccess
|
attempt.Status = coredata.AccessReviewCampaignSourceFetchStatusSuccess
|
||||||
sourceFetch.FetchedAccountsCount = fetchedAccountsCount
|
attempt.FetchedAccountsCount = fetchedAccountsCount
|
||||||
sourceFetch.LastError = nil
|
attempt.Error = nil
|
||||||
sourceFetch.CompletedAt = new(now)
|
attempt.CompletedAt = &now
|
||||||
sourceFetch.UpdatedAt = now
|
attempt.UpdatedAt = now
|
||||||
|
|
||||||
return h.pg.WithTx(
|
return h.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, tx pg.Tx) error {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fetches := coredata.AccessReviewCampaignSourceFetches{}
|
latest := coredata.AccessReviewCampaignSourceFetchAttempts{}
|
||||||
if err := fetches.LoadByCampaignID(ctx, tx, scope, campaignID); err != nil {
|
if err := latest.LoadLatestByCampaignID(ctx, tx, scope, campaignID); err != nil {
|
||||||
return fmt.Errorf("cannot load source fetches: %w", err)
|
return fmt.Errorf("cannot load latest fetch attempts: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(fetches) == 0 {
|
if len(latest) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, fetch := range fetches {
|
for _, attempt := range latest {
|
||||||
if !fetch.Status.IsTerminal() {
|
if !attempt.Status.IsTerminal() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const addSourceMutation = `
|
const addSourceMutation = `
|
||||||
mutation($input: AddAccessReviewCampaignScopeSourceInput!) {
|
mutation($input: AddAccessReviewCampaignSourceInput!) {
|
||||||
addAccessReviewCampaignScopeSource(input: $input) {
|
addAccessReviewCampaignSource(input: $input) {
|
||||||
accessReviewCampaign {
|
accessReviewCampaign {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
@@ -36,13 +36,13 @@ mutation($input: AddAccessReviewCampaignScopeSourceInput!) {
|
|||||||
`
|
`
|
||||||
|
|
||||||
type addSourceResponse struct {
|
type addSourceResponse struct {
|
||||||
AddAccessReviewCampaignScopeSource struct {
|
AddAccessReviewCampaignSource struct {
|
||||||
AccessReviewCampaign struct {
|
AccessReviewCampaign struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
} `json:"accessReviewCampaign"`
|
} `json:"accessReviewCampaign"`
|
||||||
} `json:"addAccessReviewCampaignScopeSource"`
|
} `json:"addAccessReviewCampaignSource"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCmdAddSource(f *cmdutil.Factory) *cobra.Command {
|
func NewCmdAddSource(f *cmdutil.Factory) *cobra.Command {
|
||||||
@@ -73,7 +73,7 @@ func NewCmdAddSource(f *cmdutil.Factory) *cobra.Command {
|
|||||||
|
|
||||||
input := map[string]any{
|
input := map[string]any{
|
||||||
"accessReviewCampaignId": args[0],
|
"accessReviewCampaignId": args[0],
|
||||||
"accessSourceId": flagSourceID,
|
"accessReviewSourceId": flagSourceID,
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := client.Do(
|
data, err := client.Do(
|
||||||
@@ -89,7 +89,7 @@ func NewCmdAddSource(f *cmdutil.Factory) *cobra.Command {
|
|||||||
return fmt.Errorf("cannot parse response: %w", err)
|
return fmt.Errorf("cannot parse response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c := resp.AddAccessReviewCampaignScopeSource.AccessReviewCampaign
|
c := resp.AddAccessReviewCampaignSource.AccessReviewCampaign
|
||||||
out := f.IOStreams.Out
|
out := f.IOStreams.Out
|
||||||
_, _ = fmt.Fprintf(out, "Added source %s to campaign %s\n", flagSourceID, c.ID)
|
_, _ = fmt.Fprintf(out, "Added source %s to campaign %s\n", flagSourceID, c.ID)
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(flagSourceIDs) > 0 {
|
if len(flagSourceIDs) > 0 {
|
||||||
input["accessSourceIds"] = flagSourceIDs
|
input["accessReviewSourceIds"] = flagSourceIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := client.Do(
|
data, err := client.Do(
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const removeSourceMutation = `
|
const removeSourceMutation = `
|
||||||
mutation($input: RemoveAccessReviewCampaignScopeSourceInput!) {
|
mutation($input: RemoveAccessReviewCampaignSourceInput!) {
|
||||||
removeAccessReviewCampaignScopeSource(input: $input) {
|
removeAccessReviewCampaignSource(input: $input) {
|
||||||
accessReviewCampaign {
|
accessReviewCampaign {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
@@ -36,13 +36,13 @@ mutation($input: RemoveAccessReviewCampaignScopeSourceInput!) {
|
|||||||
`
|
`
|
||||||
|
|
||||||
type removeSourceResponse struct {
|
type removeSourceResponse struct {
|
||||||
RemoveAccessReviewCampaignScopeSource struct {
|
RemoveAccessReviewCampaignSource struct {
|
||||||
AccessReviewCampaign struct {
|
AccessReviewCampaign struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
} `json:"accessReviewCampaign"`
|
} `json:"accessReviewCampaign"`
|
||||||
} `json:"removeAccessReviewCampaignScopeSource"`
|
} `json:"removeAccessReviewCampaignSource"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCmdRemoveSource(f *cmdutil.Factory) *cobra.Command {
|
func NewCmdRemoveSource(f *cmdutil.Factory) *cobra.Command {
|
||||||
@@ -73,7 +73,7 @@ func NewCmdRemoveSource(f *cmdutil.Factory) *cobra.Command {
|
|||||||
|
|
||||||
input := map[string]any{
|
input := map[string]any{
|
||||||
"accessReviewCampaignId": args[0],
|
"accessReviewCampaignId": args[0],
|
||||||
"accessSourceId": flagSourceID,
|
"accessReviewSourceId": flagSourceID,
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := client.Do(
|
data, err := client.Do(
|
||||||
@@ -89,7 +89,7 @@ func NewCmdRemoveSource(f *cmdutil.Factory) *cobra.Command {
|
|||||||
return fmt.Errorf("cannot parse response: %w", err)
|
return fmt.Errorf("cannot parse response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c := resp.RemoveAccessReviewCampaignScopeSource.AccessReviewCampaign
|
c := resp.RemoveAccessReviewCampaignSource.AccessReviewCampaign
|
||||||
out := f.IOStreams.Out
|
out := f.IOStreams.Out
|
||||||
_, _ = fmt.Fprintf(out, "Removed source %s from campaign %s\n", flagSourceID, c.ID)
|
_, _ = fmt.Fprintf(out, "Removed source %s from campaign %s\n", flagSourceID, c.ID)
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const decideMutation = `
|
const decideMutation = `
|
||||||
mutation($input: RecordAccessEntryDecisionInput!) {
|
mutation($input: RecordAccessReviewEntryDecisionInput!) {
|
||||||
recordAccessEntryDecision(input: $input) {
|
recordAccessReviewEntryDecision(input: $input) {
|
||||||
accessEntry {
|
accessEntry {
|
||||||
id
|
id
|
||||||
email
|
email
|
||||||
@@ -39,8 +39,8 @@ mutation($input: RecordAccessEntryDecisionInput!) {
|
|||||||
`
|
`
|
||||||
|
|
||||||
type decideResponse struct {
|
type decideResponse struct {
|
||||||
RecordAccessEntryDecision struct {
|
RecordAccessReviewEntryDecision struct {
|
||||||
AccessEntry struct {
|
AccessReviewEntry struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
FullName string `json:"fullName"`
|
FullName string `json:"fullName"`
|
||||||
@@ -48,7 +48,7 @@ type decideResponse struct {
|
|||||||
DecisionNote *string `json:"decisionNote"`
|
DecisionNote *string `json:"decisionNote"`
|
||||||
DecidedAt *string `json:"decidedAt"`
|
DecidedAt *string `json:"decidedAt"`
|
||||||
} `json:"accessEntry"`
|
} `json:"accessEntry"`
|
||||||
} `json:"recordAccessEntryDecision"`
|
} `json:"recordAccessReviewEntryDecision"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCmdDecide(f *cmdutil.Factory) *cobra.Command {
|
func NewCmdDecide(f *cmdutil.Factory) *cobra.Command {
|
||||||
@@ -102,8 +102,8 @@ func NewCmdDecide(f *cmdutil.Factory) *cobra.Command {
|
|||||||
)
|
)
|
||||||
|
|
||||||
input := map[string]any{
|
input := map[string]any{
|
||||||
"accessEntryId": args[0],
|
"accessReviewEntryId": args[0],
|
||||||
"decision": flagDecision,
|
"decision": flagDecision,
|
||||||
}
|
}
|
||||||
if flagNote != "" {
|
if flagNote != "" {
|
||||||
input["decisionNote"] = flagNote
|
input["decisionNote"] = flagNote
|
||||||
@@ -122,7 +122,7 @@ func NewCmdDecide(f *cmdutil.Factory) *cobra.Command {
|
|||||||
return fmt.Errorf("cannot parse response: %w", err)
|
return fmt.Errorf("cannot parse response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
e := resp.RecordAccessEntryDecision.AccessEntry
|
e := resp.RecordAccessReviewEntryDecision.AccessReviewEntry
|
||||||
|
|
||||||
if *flagOutput == cmdutil.OutputJSON {
|
if *flagOutput == cmdutil.OutputJSON {
|
||||||
return cmdutil.PrintJSON(f.IOStreams.Out, e)
|
return cmdutil.PrintJSON(f.IOStreams.Out, e)
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const decideAllMutation = `
|
const decideAllMutation = `
|
||||||
mutation($input: RecordAccessEntryDecisionsInput!) {
|
mutation($input: RecordAccessReviewEntryDecisionsInput!) {
|
||||||
recordAccessEntryDecisions(input: $input) {
|
recordAccessReviewEntryDecisions(input: $input) {
|
||||||
accessEntries {
|
accessEntries {
|
||||||
id
|
id
|
||||||
email
|
email
|
||||||
@@ -36,13 +36,13 @@ mutation($input: RecordAccessEntryDecisionsInput!) {
|
|||||||
`
|
`
|
||||||
|
|
||||||
type decideAllResponse struct {
|
type decideAllResponse struct {
|
||||||
RecordAccessEntryDecisions struct {
|
RecordAccessReviewEntryDecisions struct {
|
||||||
AccessEntries []struct {
|
AccessReviewEntries []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Decision string `json:"decision"`
|
Decision string `json:"decision"`
|
||||||
} `json:"accessEntries"`
|
} `json:"accessEntries"`
|
||||||
} `json:"recordAccessEntryDecisions"`
|
} `json:"recordAccessReviewEntryDecisions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCmdDecideAll(f *cmdutil.Factory) *cobra.Command {
|
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))
|
decisions := make([]map[string]any, len(flagEntryIDs))
|
||||||
for i, id := range flagEntryIDs {
|
for i, id := range flagEntryIDs {
|
||||||
d := map[string]any{
|
d := map[string]any{
|
||||||
"accessEntryId": id,
|
"accessReviewEntryId": id,
|
||||||
"decision": flagDecision,
|
"decision": flagDecision,
|
||||||
}
|
}
|
||||||
if flagNote != "" {
|
if flagNote != "" {
|
||||||
d["decisionNote"] = flagNote
|
d["decisionNote"] = flagNote
|
||||||
@@ -119,7 +119,7 @@ func NewCmdDecideAll(f *cmdutil.Factory) *cobra.Command {
|
|||||||
return fmt.Errorf("cannot parse response: %w", err)
|
return fmt.Errorf("cannot parse response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
entries := resp.RecordAccessEntryDecisions.AccessEntries
|
entries := resp.RecordAccessReviewEntryDecisions.AccessReviewEntries
|
||||||
|
|
||||||
if *flagOutput == cmdutil.OutputJSON {
|
if *flagOutput == cmdutil.OutputJSON {
|
||||||
return cmdutil.PrintJSON(f.IOStreams.Out, entries)
|
return cmdutil.PrintJSON(f.IOStreams.Out, entries)
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ query(
|
|||||||
$id: ID!,
|
$id: ID!,
|
||||||
$first: Int,
|
$first: Int,
|
||||||
$after: CursorKey,
|
$after: CursorKey,
|
||||||
$orderBy: AccessEntryOrder,
|
$orderBy: AccessReviewEntryOrder,
|
||||||
$accessSourceId: ID,
|
$campaignSourceId: ID,
|
||||||
$filter: AccessEntryFilter
|
$filter: AccessReviewEntryFilter
|
||||||
) {
|
) {
|
||||||
node(id: $id) {
|
node(id: $id) {
|
||||||
__typename
|
__typename
|
||||||
@@ -40,7 +40,7 @@ query(
|
|||||||
first: $first,
|
first: $first,
|
||||||
after: $after,
|
after: $after,
|
||||||
orderBy: $orderBy,
|
orderBy: $orderBy,
|
||||||
accessSourceId: $accessSourceId,
|
campaignSourceId: $campaignSourceId,
|
||||||
filter: $filter
|
filter: $filter
|
||||||
) {
|
) {
|
||||||
totalCount
|
totalCount
|
||||||
@@ -81,24 +81,24 @@ query(
|
|||||||
`
|
`
|
||||||
|
|
||||||
type entryNode struct {
|
type entryNode struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
FullName string `json:"fullName"`
|
FullName string `json:"fullName"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
JobTitle string `json:"jobTitle"`
|
JobTitle string `json:"jobTitle"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
Active *bool `json:"active"`
|
Active *bool `json:"active"`
|
||||||
MfaStatus string `json:"mfaStatus"`
|
MfaStatus string `json:"mfaStatus"`
|
||||||
AuthMethod string `json:"authMethod"`
|
AuthMethod string `json:"authMethod"`
|
||||||
AccountType string `json:"accountType"`
|
AccountType string `json:"accountType"`
|
||||||
LastLogin *string `json:"lastLogin"`
|
LastLogin *string `json:"lastLogin"`
|
||||||
ExternalID string `json:"externalId"`
|
ExternalID string `json:"externalId"`
|
||||||
IncrementalTag string `json:"incrementalTag"`
|
IncrementalTag string `json:"incrementalTag"`
|
||||||
Flags []string `json:"flags"`
|
Flags []string `json:"flags"`
|
||||||
FlagReasons []string `json:"flagReasons"`
|
FlagReasons []string `json:"flagReasons"`
|
||||||
Decision string `json:"decision"`
|
Decision string `json:"decision"`
|
||||||
DecisionNote *string `json:"decisionNote"`
|
DecisionNote *string `json:"decisionNote"`
|
||||||
AccessSource struct {
|
AccessReviewSource struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"accessSource"`
|
} `json:"accessSource"`
|
||||||
@@ -107,18 +107,18 @@ type entryNode struct {
|
|||||||
|
|
||||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||||
var (
|
var (
|
||||||
flagLimit int
|
flagLimit int
|
||||||
flagOrderBy string
|
flagOrderBy string
|
||||||
flagOrderDir string
|
flagOrderDir string
|
||||||
flagSourceID string
|
flagCampaignSourceID string
|
||||||
flagDecision string
|
flagDecision string
|
||||||
flagFlag string
|
flagFlag string
|
||||||
flagIncTag string
|
flagIncTag string
|
||||||
flagIsAdmin *bool
|
flagIsAdmin *bool
|
||||||
flagActive *bool
|
flagActive *bool
|
||||||
flagAuthMethod string
|
flagAuthMethod string
|
||||||
flagAccountType string
|
flagAccountType string
|
||||||
flagOutput *string
|
flagOutput *string
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@@ -129,7 +129,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
|||||||
prb access-review entry list <campaign-id>
|
prb access-review entry list <campaign-id>
|
||||||
|
|
||||||
# List entries for a specific source
|
# List entries for a specific source
|
||||||
prb access-review entry list <campaign-id> --source-id <source-id>
|
prb access-review entry list <campaign-id> --campaign-source-id <source-id>
|
||||||
|
|
||||||
# List only pending entries
|
# List only pending entries
|
||||||
prb access-review entry list <campaign-id> --decision PENDING
|
prb access-review entry list <campaign-id> --decision PENDING
|
||||||
@@ -178,8 +178,8 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if flagSourceID != "" {
|
if flagCampaignSourceID != "" {
|
||||||
variables["accessSourceId"] = flagSourceID
|
variables["campaignSourceId"] = flagCampaignSourceID
|
||||||
}
|
}
|
||||||
|
|
||||||
filter := map[string]any{}
|
filter := map[string]any{}
|
||||||
@@ -326,7 +326,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
|||||||
e.ID,
|
e.ID,
|
||||||
e.Email,
|
e.Email,
|
||||||
e.FullName,
|
e.FullName,
|
||||||
e.AccessSource.Name,
|
e.AccessReviewSource.Name,
|
||||||
e.Decision,
|
e.Decision,
|
||||||
strings.Join(e.Flags, ","),
|
strings.Join(e.Flags, ","),
|
||||||
admin,
|
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().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(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT)")
|
||||||
cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)")
|
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(&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(&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)")
|
cmd.Flags().StringVar(&flagIncTag, "incremental-tag", "", "Filter by incremental tag (NEW, REMOVED, UNCHANGED)")
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const flagMutation = `
|
const flagMutation = `
|
||||||
mutation($input: FlagAccessEntryInput!) {
|
mutation($input: FlagAccessReviewEntryInput!) {
|
||||||
flagAccessEntry(input: $input) {
|
flagAccessReviewEntry(input: $input) {
|
||||||
accessEntry {
|
accessEntry {
|
||||||
id
|
id
|
||||||
email
|
email
|
||||||
@@ -40,8 +40,8 @@ mutation($input: FlagAccessEntryInput!) {
|
|||||||
`
|
`
|
||||||
|
|
||||||
type flagResponse struct {
|
type flagResponse struct {
|
||||||
FlagAccessEntry struct {
|
FlagAccessReviewEntry struct {
|
||||||
AccessEntry struct {
|
AccessReviewEntry struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
FullName string `json:"fullName"`
|
FullName string `json:"fullName"`
|
||||||
@@ -49,7 +49,7 @@ type flagResponse struct {
|
|||||||
FlagReasons []string `json:"flagReasons"`
|
FlagReasons []string `json:"flagReasons"`
|
||||||
Decision string `json:"decision"`
|
Decision string `json:"decision"`
|
||||||
} `json:"accessEntry"`
|
} `json:"accessEntry"`
|
||||||
} `json:"flagAccessEntry"`
|
} `json:"flagAccessReviewEntry"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCmdFlag(f *cmdutil.Factory) *cobra.Command {
|
func NewCmdFlag(f *cmdutil.Factory) *cobra.Command {
|
||||||
@@ -107,8 +107,8 @@ func NewCmdFlag(f *cmdutil.Factory) *cobra.Command {
|
|||||||
)
|
)
|
||||||
|
|
||||||
input := map[string]any{
|
input := map[string]any{
|
||||||
"accessEntryId": args[0],
|
"accessReviewEntryId": args[0],
|
||||||
"flags": flagFlags,
|
"flags": flagFlags,
|
||||||
}
|
}
|
||||||
if flagReason != "" {
|
if flagReason != "" {
|
||||||
input["flagReasons"] = []string{flagReason}
|
input["flagReasons"] = []string{flagReason}
|
||||||
@@ -127,7 +127,7 @@ func NewCmdFlag(f *cmdutil.Factory) *cobra.Command {
|
|||||||
return fmt.Errorf("cannot parse response: %w", err)
|
return fmt.Errorf("cannot parse response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
e := resp.FlagAccessEntry.AccessEntry
|
e := resp.FlagAccessReviewEntry.AccessReviewEntry
|
||||||
|
|
||||||
if *flagOutput == cmdutil.OutputJSON {
|
if *flagOutput == cmdutil.OutputJSON {
|
||||||
return cmdutil.PrintJSON(f.IOStreams.Out, e)
|
return cmdutil.PrintJSON(f.IOStreams.Out, e)
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const createMutation = `
|
const createMutation = `
|
||||||
mutation($input: CreateAccessSourceInput!) {
|
mutation($input: CreateAccessReviewSourceInput!) {
|
||||||
createAccessSource(input: $input) {
|
createAccessReviewSource(input: $input) {
|
||||||
accessSourceEdge {
|
accessSourceEdge {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
@@ -38,14 +38,14 @@ mutation($input: CreateAccessSourceInput!) {
|
|||||||
`
|
`
|
||||||
|
|
||||||
type createResponse struct {
|
type createResponse struct {
|
||||||
CreateAccessSource struct {
|
CreateAccessReviewSource struct {
|
||||||
AccessSourceEdge struct {
|
AccessReviewSourceEdge struct {
|
||||||
Node struct {
|
Node struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"node"`
|
} `json:"node"`
|
||||||
} `json:"accessSourceEdge"`
|
} `json:"accessSourceEdge"`
|
||||||
} `json:"createAccessSource"`
|
} `json:"createAccessReviewSource"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
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)
|
return fmt.Errorf("cannot parse response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s := resp.CreateAccessSource.AccessSourceEdge.Node
|
s := resp.CreateAccessReviewSource.AccessReviewSourceEdge.Node
|
||||||
out := f.IOStreams.Out
|
out := f.IOStreams.Out
|
||||||
_, _ = fmt.Fprintf(out, "Created access source %s\n", s.ID)
|
_, _ = fmt.Fprintf(out, "Created access source %s\n", s.ID)
|
||||||
_, _ = fmt.Fprintf(out, "Name: %s\n", s.Name)
|
_, _ = fmt.Fprintf(out, "Name: %s\n", s.Name)
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const deleteMutation = `
|
const deleteMutation = `
|
||||||
mutation($input: DeleteAccessSourceInput!) {
|
mutation($input: DeleteAccessReviewSourceInput!) {
|
||||||
deleteAccessSource(input: $input) {
|
deleteAccessReviewSource(input: $input) {
|
||||||
deletedAccessSourceId
|
deletedAccessReviewSourceId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
@@ -81,7 +81,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
|||||||
deleteMutation,
|
deleteMutation,
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
"accessSourceId": args[0],
|
"accessReviewSourceId": args[0],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const listQuery = `
|
const listQuery = `
|
||||||
query($id: ID!, $first: Int, $after: CursorKey, $orderBy: AccessSourceOrder) {
|
query($id: ID!, $first: Int, $after: CursorKey, $orderBy: AccessReviewSourceOrder) {
|
||||||
node(id: $id) {
|
node(id: $id) {
|
||||||
__typename
|
__typename
|
||||||
... on Organization {
|
... on Organization {
|
||||||
accessSources(first: $first, after: $after, orderBy: $orderBy) {
|
accessReviewSources(first: $first, after: $after, orderBy: $orderBy) {
|
||||||
totalCount
|
totalCount
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
@@ -125,8 +125,8 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
|||||||
func(data json.RawMessage) (*api.Connection[sourceNode], error) {
|
func(data json.RawMessage) (*api.Connection[sourceNode], error) {
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Node *struct {
|
Node *struct {
|
||||||
Typename string `json:"__typename"`
|
Typename string `json:"__typename"`
|
||||||
AccessSources api.Connection[sourceNode] `json:"accessSources"`
|
AccessReviewSources api.Connection[sourceNode] `json:"accessReviewSources"`
|
||||||
} `json:"node"`
|
} `json:"node"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(data, &resp); err != nil {
|
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 nil, fmt.Errorf("expected Organization node, got %s", resp.Node.Typename)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &resp.Node.AccessSources, nil
|
return &resp.Node.AccessReviewSources, nil
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const updateMutation = `
|
const updateMutation = `
|
||||||
mutation($input: UpdateAccessSourceInput!) {
|
mutation($input: UpdateAccessReviewSourceInput!) {
|
||||||
updateAccessSource(input: $input) {
|
updateAccessReviewSource(input: $input) {
|
||||||
accessSource {
|
accessSource {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
@@ -36,12 +36,12 @@ mutation($input: UpdateAccessSourceInput!) {
|
|||||||
`
|
`
|
||||||
|
|
||||||
type updateResponse struct {
|
type updateResponse struct {
|
||||||
UpdateAccessSource struct {
|
UpdateAccessReviewSource struct {
|
||||||
AccessSource struct {
|
AccessReviewSource struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"accessSource"`
|
} `json:"accessSource"`
|
||||||
} `json:"updateAccessSource"`
|
} `json:"updateAccessReviewSource"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||||
@@ -80,7 +80,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
|||||||
)
|
)
|
||||||
|
|
||||||
input := map[string]any{
|
input := map[string]any{
|
||||||
"accessSourceId": args[0],
|
"accessReviewSourceId": args[0],
|
||||||
}
|
}
|
||||||
|
|
||||||
if cmd.Flags().Changed("name") {
|
if cmd.Flags().Changed("name") {
|
||||||
@@ -113,7 +113,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
|||||||
return fmt.Errorf("cannot parse response: %w", err)
|
return fmt.Errorf("cannot parse response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s := resp.UpdateAccessSource.AccessSource
|
s := resp.UpdateAccessReviewSource.AccessReviewSource
|
||||||
|
|
||||||
if *flagOutput == cmdutil.OutputJSON {
|
if *flagOutput == cmdutil.OutputJSON {
|
||||||
return cmdutil.PrintJSON(f.IOStreams.Out, s)
|
return cmdutil.PrintJSON(f.IOStreams.Out, s)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const viewQuery = `
|
|||||||
query($id: ID!) {
|
query($id: ID!) {
|
||||||
node(id: $id) {
|
node(id: $id) {
|
||||||
__typename
|
__typename
|
||||||
... on AccessSource {
|
... on AccessReviewSource {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
connectorId
|
connectorId
|
||||||
@@ -97,8 +97,8 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
|||||||
return fmt.Errorf("access source %s not found", args[0])
|
return fmt.Errorf("access source %s not found", args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.Node.Typename != "AccessSource" {
|
if resp.Node.Typename != "AccessReviewSource" {
|
||||||
return fmt.Errorf("expected AccessSource node, got %s", resp.Node.Typename)
|
return fmt.Errorf("expected AccessReviewSource node, got %s", resp.Node.Typename)
|
||||||
}
|
}
|
||||||
|
|
||||||
if *flagOutput == cmdutil.OutputJSON {
|
if *flagOutput == cmdutil.OutputJSON {
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
|
||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,486 +0,0 @@
|
|||||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
@@ -54,6 +54,34 @@ func (c AccessReviewCampaign) CursorKey(orderBy AccessReviewCampaignOrderField)
|
|||||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
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(
|
func (c *AccessReviewCampaign) AuthorizationAttributes(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user