Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/api/combinedGrantsExt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import {
} from 'src/services/supabase';
import { getCountSettings } from 'src/utils/table-utils';

// Service accounts are backed by a synthetic auth.users row whose email always
// ends in this domain (see internal.service_accounts). They surface in
// combined_grants_ext like any human user, so the user-listing queries below
// exclude them by email suffix.
const SERVICE_ACCOUNT_EMAIL_PATTERN = '%@service_accounts.estuary.dev';

// Used to display prefix grants in admin page
const getGrants = (
pagination: any,
Expand Down Expand Up @@ -64,7 +70,8 @@ const getGrants_Users = (
count: 'exact',
}
)
.or('user_email.neq.null,user_full_name.neq.null');
.or('user_email.neq.null,user_full_name.neq.null')
.not('user_email', 'like', SERVICE_ACCOUNT_EMAIL_PATTERN);

return defaultTableFilter<typeof query>(
query,
Expand Down Expand Up @@ -120,6 +127,7 @@ const getUserInformationByPrefix = (
.in('object_role', evaluatedObjectRoles)
.is('subject_role', null)
.filter('user_email', 'not.is', null)
.not('user_email', 'like', SERVICE_ACCOUNT_EMAIL_PATTERN)
.returns<Grant_UserExt[]>();
};

Expand Down
202 changes: 202 additions & 0 deletions src/api/gql/serviceAccounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import type { ServiceAccount } from 'src/gql-types/graphql';

import { useCallback, useMemo } from 'react';

import { useMutation, useQuery } from 'urql';

import { graphql } from 'src/gql-types';

const DEFAULT_SERVICE_ACCOUNTS: ServiceAccount[] = [];

export const SERVICE_ACCOUNTS_PAGE_SIZE = 10;

const SERVICE_ACCOUNTS_QUERY = graphql(`
query ServiceAccounts($first: Int, $after: String) {
serviceAccounts(first: $first, after: $after) {
edges {
node {
catalogName
createdAt
createdBy
updatedAt
lastUsedAt
grants {
prefix
capability
createdAt
detail
updatedAt
}
tokens {
id
detail
createdAt
createdBy
expiresAt
lastUsedAt
}
}
cursor
}
pageInfo {
...PageInfoFields
}
}
}
`);

export function useServiceAccounts(afterCursor?: string) {
const [{ fetching, data, error }] = useQuery({
query: SERVICE_ACCOUNTS_QUERY,
variables: {
first: SERVICE_ACCOUNTS_PAGE_SIZE,
after: afterCursor,
},
});

const serviceAccounts = useMemo(
() =>
data?.serviceAccounts?.edges?.map((edge) => edge.node) ??
DEFAULT_SERVICE_ACCOUNTS,
[data]
);

const pageInfo = data?.serviceAccounts?.pageInfo ?? null;

return {
serviceAccounts,
fetching,
error,
pageInfo,
pageSize: SERVICE_ACCOUNTS_PAGE_SIZE,
};
}

// The schema exposes service accounts only as a paginated connection — there is
// no by-name lookup yet. The detail page loads a generous page and finds the
// account client-side, which is fine for realistic account counts. A dedicated
// `serviceAccount(catalogName)` field would make this an O(1) fetch.
const SERVICE_ACCOUNT_LOOKUP_LIMIT = 250;

export function useServiceAccount(catalogName: string | null) {
const [{ fetching, data, error }, reexecuteQuery] = useQuery({
query: SERVICE_ACCOUNTS_QUERY,
variables: { first: SERVICE_ACCOUNT_LOOKUP_LIMIT },
pause: !catalogName,
});

const serviceAccount = useMemo(
() =>
catalogName
? (data?.serviceAccounts?.edges
?.map((edge) => edge.node)
.find((node) => node.catalogName === catalogName) ?? null)
: null,
[data, catalogName]
);

// Token mutations don't return the ServiceAccount type, so URQL won't
// invalidate this query automatically — callers refetch after minting or
// revoking a key.
const refetch = useCallback(
() => reexecuteQuery({ requestPolicy: 'network-only' }),
[reexecuteQuery]
);

return { serviceAccount, fetching, error, refetch };
}

// A service account is homed at `catalogName` (its management anchor) and
// seeded with one or more grants, each granting a capability on a prefix.
const CREATE_SERVICE_ACCOUNT = graphql(`
mutation CreateServiceAccount(
$catalogName: Name!
$grants: [ServiceAccountGrantInput!]!
) {
createServiceAccount(catalogName: $catalogName, grants: $grants) {
catalogName
createdAt
createdBy
}
}
`);

// Mints a credential (refresh token) owned by the account. The secret is
// returned exactly once and cannot be retrieved again.
const CREATE_SERVICE_ACCOUNT_TOKEN = graphql(`
mutation CreateServiceAccountToken(
$catalogName: Name!
$detail: String!
$validFor: String!
) {
createServiceAccountToken(
catalogName: $catalogName
detail: $detail
validFor: $validFor
) {
id
secret
}
}
`);

const REVOKE_SERVICE_ACCOUNT_TOKEN = graphql(`
mutation RevokeServiceAccountToken($id: Id!) {
revokeServiceAccountToken(id: $id)
}
`);

// Grants a capability on a prefix to an existing account. Re-adding a prefix
// the account already has updates its capability, so this also backs the
// "edit capability" action.
const ADD_SERVICE_ACCOUNT_GRANT = graphql(`
mutation AddServiceAccountGrant(
$catalogName: Name!
$prefix: Prefix!
$capability: Capability!
) {
addServiceAccountGrant(
catalogName: $catalogName
prefix: $prefix
capability: $capability
)
}
`);

const REMOVE_SERVICE_ACCOUNT_GRANT = graphql(`
mutation RemoveServiceAccountGrant($catalogName: Name!, $prefix: Prefix!) {
removeServiceAccountGrant(catalogName: $catalogName, prefix: $prefix)
}
`);

// Revokes every active token the account owns. Used when removing an account's
// last grant: a credential with no access left is worth retiring.
const REVOKE_ALL_SERVICE_ACCOUNT_TOKENS = graphql(`
mutation RevokeAllServiceAccountTokens($catalogName: Name!) {
revokeAllServiceAccountTokens(catalogName: $catalogName)
}
`);

export function useCreateServiceAccount() {
return useMutation(CREATE_SERVICE_ACCOUNT);
}

export function useCreateServiceAccountToken() {
return useMutation(CREATE_SERVICE_ACCOUNT_TOKEN);
}

export function useRevokeServiceAccountToken() {
return useMutation(REVOKE_SERVICE_ACCOUNT_TOKEN);
}

export function useAddServiceAccountGrant() {
return useMutation(ADD_SERVICE_ACCOUNT_GRANT);
}

export function useRemoveServiceAccountGrant() {
return useMutation(REMOVE_SERVICE_ACCOUNT_GRANT);
}

export function useRevokeAllServiceAccountTokens() {
return useMutation(REVOKE_ALL_SERVICE_ACCOUNT_TOKENS);
}
31 changes: 31 additions & 0 deletions src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ const admin = {
fullPath: '/admin/billing/paymentMethod/new',
},
},
serviceAccounts: {
title: 'routeTitle.admin.serviceAccounts',
path: 'serviceAccounts',
fullPath: '/admin/serviceAccounts',
details: {
title: 'routeTitle.admin.serviceAccounts.details',
path: 'details',
fullPath: '/admin/serviceAccounts/details',
},
},
settings: {
title: 'routeTitle.admin.settings',
path: 'settings',
Expand Down Expand Up @@ -154,6 +164,15 @@ const express = {
},
};

const flowctl = {
accessToken: {
title: 'routeTitle.flowctl.accessToken',
path: 'accessToken',
fullPath: '/flowctl/accessToken',
},
path: 'flowctl',
};

const home = {
title: 'routeTitle.home',
path: '/welcome',
Expand Down Expand Up @@ -235,6 +254,16 @@ const pageNotFound = {
path: '*',
};

const settings = {
title: 'routeTitle.settings',
path: 'settings',
personalTokens: {
title: 'routeTitle.settings.personalTokens',
path: 'personalTokens',
fullPath: '/settings/personalTokens',
},
};

const user = {
title: 'routeTitle.user',
path: 'user',
Expand Down Expand Up @@ -264,11 +293,13 @@ export const authenticatedRoutes = {
collections,
dataPlaneAuth,
express,
flowctl,
home,
materializations,
marketplace: marketplace.authenticated,
user,
pageNotFound,
settings,
beta,
};

Expand Down
39 changes: 0 additions & 39 deletions src/components/admin/Api/AccessToken.tsx

This file was deleted.

Loading
Loading