chore(core): Use roles from database in global roles (#18768)

This commit is contained in:
Andreas Fitzek
2025-08-26 17:53:46 +02:00
committed by GitHub
parent cff3f4a67e
commit ecad12b77a
117 changed files with 956 additions and 424 deletions

View File

@@ -95,6 +95,8 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
"workflow:share",
"workflow:execute",
"workflow:move",
"workflow:activate",
"workflow:deactivate",
"workflow:create",
"workflow:read",
"workflow:update",
@@ -121,6 +123,14 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
"dataStore:writeRow",
"dataStore:listProject",
"dataStore:*",
"execution:delete",
"execution:read",
"execution:list",
"execution:get",
"execution:*",
"workflowTags:update",
"workflowTags:list",
"workflowTags:*",
"*",
]
`;

View File

@@ -22,11 +22,13 @@ export const RESOURCES = {
user: ['resetPassword', 'changeRole', 'enforceMfa', ...DEFAULT_OPERATIONS] as const,
variable: [...DEFAULT_OPERATIONS] as const,
workersView: ['manage'] as const,
workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const,
workflow: ['share', 'execute', 'move', 'activate', 'deactivate', ...DEFAULT_OPERATIONS] as const,
folder: [...DEFAULT_OPERATIONS, 'move'] as const,
insights: ['list'] as const,
oidc: ['manage'] as const,
dataStore: [...DEFAULT_OPERATIONS, 'readRow', 'writeRow', 'listProject'] as const,
execution: ['delete', 'read', 'list', 'get'] as const,
workflowTags: ['update', 'list'] as const,
} as const;
export const API_KEY_RESOURCES = {

View File

@@ -13,7 +13,7 @@ export { hasGlobalScope } from './utilities/has-global-scope.ee';
export { combineScopes } from './utilities/combine-scopes.ee';
export { rolesWithScope } from './utilities/roles-with-scope.ee';
export { getGlobalScopes } from './utilities/get-global-scopes.ee';
export { getRoleScopes } from './utilities/get-role-scopes.ee';
export { getRoleScopes, getAuthPrincipalScopes } from './utilities/get-role-scopes.ee';
export { getResourcePermissions } from './utilities/get-resource-permissions.ee';
export type { PermissionsRecord } from './utilities/get-resource-permissions.ee';
export * from './public-api-permissions.ee';

View File

@@ -1,4 +1,4 @@
import type { ApiKeyScope, GlobalRole } from './types.ee';
import { isApiKeyScope, type ApiKeyScope, type AuthPrincipal, type GlobalRole } from './types.ee';
export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [
'user:read',
@@ -65,14 +65,46 @@ export const MEMBER_API_KEY_SCOPES: ApiKeyScope[] = [
'credential:delete',
];
/**
* This is a bit of a mess, because we are handing out scopes in API keys that are only
* valid for the personal project, which is enforced in the public API, because the workflows,
* execution endpoints are limited to the personal project.
* This is a temporary solution until we have a better way to handle personal projects and API key scopes!
*/
export const API_KEY_SCOPES_FOR_IMPLICIT_PERSONAL_PROJECT: ApiKeyScope[] = [
'workflowTags:update',
'workflowTags:list',
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:delete',
'workflow:list',
'workflow:move',
'workflow:activate',
'workflow:deactivate',
'execution:delete',
'execution:read',
'execution:list',
'credential:create',
'credential:move',
'credential:delete',
];
const MAP_ROLE_SCOPES: Record<GlobalRole, ApiKeyScope[]> = {
'global:owner': OWNER_API_KEY_SCOPES,
'global:admin': ADMIN_API_KEY_SCOPES,
'global:member': MEMBER_API_KEY_SCOPES,
};
export const getApiKeyScopesForRole = (role: GlobalRole) => {
return MAP_ROLE_SCOPES[role];
export const getApiKeyScopesForRole = (user: AuthPrincipal) => {
return [
...new Set(
user.role.scopes
.map((scope) => scope.slug)
.concat(API_KEY_SCOPES_FOR_IMPLICIT_PERSONAL_PROJECT)
.filter(isApiKeyScope),
),
];
};
export const getOwnerOnlyApiKeyScopes = () => {

View File

@@ -1,5 +1,5 @@
import { RESOURCES } from './constants.ee';
import type { Scope, ScopeInformation } from './types.ee';
import { API_KEY_RESOURCES, RESOURCES } from './constants.ee';
import type { ApiKeyScope, Scope, ScopeInformation } from './types.ee';
function buildResourceScopes() {
const resourceScopes = Object.entries(RESOURCES).flatMap(([resource, operations]) => [
@@ -11,8 +11,18 @@ function buildResourceScopes() {
return resourceScopes;
}
function buildApiKeyScopes() {
const apiKeyScopes = Object.entries(API_KEY_RESOURCES).flatMap(([resource, operations]) => [
...operations.map((op) => `${resource}:${op}` as const),
]) as ApiKeyScope[];
return new Set(apiKeyScopes);
}
export const ALL_SCOPES = buildResourceScopes();
export const ALL_API_KEY_SCOPES = buildApiKeyScopes();
export const scopeInformation: Partial<Record<Scope, ScopeInformation>> = {
'annotationTag:create': {
displayName: 'Create Annotation Tag',

View File

@@ -10,6 +10,7 @@ import type {
teamRoleSchema,
workflowSharingRoleSchema,
} from './schemas.ee';
import { ALL_API_KEY_SCOPES } from './scope-information';
export type ScopeInformation = {
displayName: string;
@@ -76,12 +77,21 @@ export type AllRolesMap = {
workflow: Array<RoleObject<WorkflowSharingRole>>;
};
export type DbScope = {
slug: Scope;
};
export type DbRole = {
slug: string;
scopes: DbScope[];
};
/**
* Represents an authenticated entity in the system that can have specific permissions via a role.
* @property role - The global role this principal has
*/
export type AuthPrincipal = {
role: GlobalRole;
role: DbRole;
};
// #region Public API
@@ -101,4 +111,9 @@ type AllApiKeyScopesObject = {
export type ApiKeyScope = AllApiKeyScopesObject[PublicApiKeyResources];
export function isApiKeyScope(scope: Scope): scope is ApiKeyScope {
// We are casting with as for runtime type checking
return ALL_API_KEY_SCOPES.has(scope as ApiKeyScope);
}
// #endregion

View File

@@ -1,19 +1,19 @@
import { GLOBAL_SCOPE_MAP } from '../../roles/role-maps.ee';
import type { GlobalRole } from '../../types.ee';
import { getGlobalScopes } from '../get-global-scopes.ee';
import { createAuthPrincipal } from './utils';
describe('getGlobalScopes', () => {
test.each(['global:owner', 'global:admin', 'global:member'] as const)(
'should return correct scopes for %s',
(role) => {
const scopes = getGlobalScopes({ role });
const scopes = getGlobalScopes(createAuthPrincipal(role));
expect(scopes).toEqual(GLOBAL_SCOPE_MAP[role]);
},
);
test('should return empty array for non-existent role', () => {
const scopes = getGlobalScopes({ role: 'non:existent' as GlobalRole });
const scopes = getGlobalScopes(createAuthPrincipal('non:existent'));
expect(scopes).toEqual([]);
});

View File

@@ -15,6 +15,7 @@ describe('permissions', () => {
externalSecretsProvider: {},
externalSecret: {},
eventBusDestination: {},
execution: {},
ldap: {},
license: {},
logStreaming: {},
@@ -29,6 +30,7 @@ describe('permissions', () => {
variable: {},
workersView: {},
workflow: {},
workflowTags: {},
folder: {},
insights: {},
dataStore: {},
@@ -130,6 +132,8 @@ describe('permissions', () => {
list: true,
},
dataStore: {},
execution: {},
workflowTags: {},
};
expect(getResourcePermissions(scopes)).toEqual(permissionRecord);

View File

@@ -1,5 +1,6 @@
import type { GlobalRole, Scope } from '../../types.ee';
import { hasGlobalScope } from '../has-global-scope.ee';
import { createAuthPrincipal } from './utils';
describe('hasGlobalScope', () => {
describe('single scope checks', () => {
@@ -11,7 +12,7 @@ describe('hasGlobalScope', () => {
] as Array<{ role: GlobalRole; scope: Scope; expected: boolean }>)(
'$role with $scope -> $expected',
({ role, scope, expected }) => {
expect(hasGlobalScope({ role }, scope)).toBe(expected);
expect(hasGlobalScope(createAuthPrincipal(role), scope)).toBe(expected);
},
);
});
@@ -19,7 +20,7 @@ describe('hasGlobalScope', () => {
describe('multiple scopes', () => {
test('oneOf mode (default)', () => {
expect(
hasGlobalScope({ role: 'global:member' }, [
hasGlobalScope(createAuthPrincipal('global:member'), [
'tag:create',
'user:list',
// a member cannot create users
@@ -31,7 +32,7 @@ describe('hasGlobalScope', () => {
test('allOf mode', () => {
expect(
hasGlobalScope(
{ role: 'global:member' },
createAuthPrincipal('global:member'),
[
'tag:create',
'user:list',
@@ -45,6 +46,6 @@ describe('hasGlobalScope', () => {
});
test('edge cases', () => {
expect(hasGlobalScope({ role: 'global:owner' }, [])).toBe(false);
expect(hasGlobalScope(createAuthPrincipal('global:owner'), [])).toBe(false);
});
});

View File

@@ -0,0 +1,40 @@
import { GLOBAL_SCOPE_MAP } from '@/roles/role-maps.ee';
import { globalRoleSchema } from '@/schemas.ee';
import type { AuthPrincipal, GlobalRole, Scope } from '@/types.ee';
function createBuildInAuthPrincipal(role: GlobalRole): AuthPrincipal {
return {
role: {
slug: role,
scopes:
GLOBAL_SCOPE_MAP[role].map((scope) => {
return {
slug: scope,
};
}) || [],
},
};
}
export function createAuthPrincipal(role: string, scopes: Scope[] = []): AuthPrincipal {
try {
const isGlobalRole = globalRoleSchema.parse(role);
if (isGlobalRole) {
return createBuildInAuthPrincipal(isGlobalRole);
}
} catch (error) {
// If the role is not a valid global role, we proceed
// to create a custom role with the provided scopes.
}
return {
role: {
slug: role,
scopes:
scopes.map((scope) => {
return {
slug: scope,
};
}) || [],
},
};
}

View File

@@ -1,4 +1,3 @@
import { GLOBAL_SCOPE_MAP } from '../roles/role-maps.ee';
import type { AuthPrincipal } from '../types.ee';
/**
@@ -6,4 +5,5 @@ import type { AuthPrincipal } from '../types.ee';
* @param principal - Contains the role to look up
* @returns Array of scopes for the role, or empty array if not found
*/
export const getGlobalScopes = (principal: AuthPrincipal) => GLOBAL_SCOPE_MAP[principal.role] ?? [];
export const getGlobalScopes = (principal: AuthPrincipal) =>
principal.role.scopes.map((scope) => scope.slug) ?? [];

View File

@@ -1,5 +1,5 @@
import { ALL_ROLE_MAPS } from '../roles/role-maps.ee';
import type { AllRoleTypes, Resource, Scope } from '../types.ee';
import type { AllRoleTypes, AuthPrincipal, Resource, Scope } from '../types.ee';
export const COMBINED_ROLE_MAP = Object.fromEntries(
Object.values(ALL_ROLE_MAPS).flatMap((o: Record<string, Scope[]>) => Object.entries(o)),
@@ -10,6 +10,8 @@ export const COMBINED_ROLE_MAP = Object.fromEntries(
* @param role - The role to look up
* @param filters - Optional resources to filter scopes by
* @returns Array of matching scopes
*
* @deprecated Use the 'getRoleScopes' from the AuthRolesService instead.
*/
export function getRoleScopes(role: AllRoleTypes, filters?: Resource[]): Scope[] {
let scopes = COMBINED_ROLE_MAP[role];
@@ -18,3 +20,22 @@ export function getRoleScopes(role: AllRoleTypes, filters?: Resource[]): Scope[]
}
return scopes;
}
/**
* Gets scopes for an auth principal, optionally filtered by resource types.
* @param user - The auth principal to search scopes for
* @param filters - Optional resources to filter scopes by
* @returns Array of matching scopes
*/
export function getAuthPrincipalScopes(user: AuthPrincipal, filters?: Resource[]): Scope[] {
if (!user.role) {
const e = new Error('AuthPrincipal does not have a role defined');
console.error('AuthPrincipal does not have a role defined', e);
throw e;
}
let scopes = user.role.scopes.map((s) => s.slug);
if (filters) {
scopes = scopes.filter((s) => filters.includes(s.split(':')[0] as Resource));
}
return scopes;
}

View File

@@ -1,6 +1,6 @@
import { getGlobalScopes } from './get-global-scopes.ee';
import { hasScope } from './has-scope.ee';
import type { AuthPrincipal, Scope, ScopeOptions } from '../types.ee';
import { getAuthPrincipalScopes } from './get-role-scopes.ee';
/**
* Checks if an auth-principal has specified global scope(s).
@@ -12,6 +12,6 @@ export const hasGlobalScope = (
scope: Scope | Scope[],
scopeOptions?: ScopeOptions,
): boolean => {
const global = getGlobalScopes(principal);
const global = getAuthPrincipalScopes(principal);
return hasScope(scope, { global }, undefined, scopeOptions);
};