Files
api/web/utils/authorizationScopes.ts
Eli Bosley 674323fd87 feat: generated UI API key management + OAuth-like API Key Flows (#1609)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* API Key Authorization flow with consent screen, callback support, and
a Tools page.
* Schema-driven API Key creation UI with permission presets, templates,
and Developer Authorization Link.
* Effective Permissions preview and a new multi-select permission
control.

* **UI Improvements**
* Mask/toggle API keys, copy-to-clipboard with toasts, improved select
labels, new label styles, tab wrapping, and accordionized color
controls.

* **Documentation**
  * Public guide for the API Key authorization flow and scopes added.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-27 12:37:39 -04:00

311 lines
9.2 KiB
TypeScript

import { AuthAction, Resource, Role } from '~/composables/gql/graphql.js';
export interface RawPermission {
resource: string;
actions: AuthAction[];
}
export interface AuthorizationLinkParams {
appName: string;
appDescription?: string;
roles?: Role[];
rawPermissions?: RawPermission[];
redirectUrl?: string;
state?: string;
}
export interface AuthorizationFormData {
name: string;
description: string;
roles?: Role[];
customPermissions?: Array<{
resources: Resource[];
actions: AuthAction[];
}>;
}
/**
* Convert AuthAction enum to simple action verb
* E.g., CREATE_ANY -> create, READ_OWN -> read
* @deprecated Use extractActionWithPossession for possession-aware extraction
*/
export function extractActionVerb(action: AuthAction | string): string {
const actionStr = String(action);
// Handle enum values like CREATE_ANY, READ_ANY
if (actionStr.includes('_')) {
return actionStr.split('_')[0].toLowerCase();
}
// Handle scope format like 'read:any'
if (actionStr.includes(':')) {
return actionStr.split(':')[0].toLowerCase();
}
// Already just a verb
return actionStr.toLowerCase();
}
/**
* Convert AuthAction enum to lowercase format with possession
* E.g., CREATE_ANY -> create_any, READ_OWN -> read_own
*/
export function extractActionWithPossession(action: AuthAction | string): string {
// Just convert the full enum value to lowercase
return String(action).toLowerCase();
}
/**
* Encode permissions into efficient scope strings
* Groups resources with identical action sets using a compact format:
* - Individual: "docker:read_any", "vms:update_own"
* - Grouped: "docker+vms:read_any+update_own" (resources sharing same actions)
*/
export function encodePermissionsToScopes(roles: Role[] = [], rawPermissions: RawPermission[] = []): string[] {
const scopes: string[] = [];
// Add role scopes
for (const role of roles) {
scopes.push(`role:${role.toLowerCase()}`);
}
// Skip empty permissions
const validPermissions = rawPermissions.filter(perm => perm.actions && perm.actions.length > 0);
// First, merge all permissions for the same resource
const resourceActionsMap = new Map<string, Set<string>>();
for (const perm of validPermissions) {
const resourceName = perm.resource.toLowerCase();
if (!resourceActionsMap.has(resourceName)) {
resourceActionsMap.set(resourceName, new Set<string>());
}
const actionsSet = resourceActionsMap.get(resourceName)!;
for (const action of perm.actions) {
actionsSet.add(extractActionWithPossession(action));
}
}
// Now group resources by their action sets for efficient encoding
const actionGroups = new Map<string, Set<string>>();
for (const [resourceName, actionsSet] of resourceActionsMap) {
const actionsWithPossession = Array.from(actionsSet).sort();
// Create a key from the sorted actions
const actionKey = actionsWithPossession.join('+');
if (!actionGroups.has(actionKey)) {
actionGroups.set(actionKey, new Set<string>());
}
actionGroups.get(actionKey)!.add(resourceName);
}
// Generate efficient scopes
for (const [actions, resourcesSet] of actionGroups.entries()) {
// Convert Set to sorted array for consistent output
const resources = Array.from(resourcesSet).sort();
if (resources.length === 1) {
// Single resource: "docker:read_any+update_own"
scopes.push(`${resources[0]}:${actions}`);
} else {
// Multiple resources with same actions: "docker+vms:read_any+update_own"
scopes.push(`${resources.join('+')}:${actions}`);
}
}
return scopes;
}
/**
* Decode scope strings back to permissions and roles
* Supports both individual and grouped formats:
* - "role:admin" -> role
* - "docker:read_any" -> single permission with possession
* - "docker+vms:read_any+update_own" -> multiple permissions with same actions
* - "docker:*" -> wildcard (all CRUD actions)
*/
export function decodeScopesToPermissions(scopes: string[]): {
permissions: Array<{ resource: Resource; actions: AuthAction[] }>,
roles: Role[]
} {
const roles: Role[] = [];
// Use a map to merge permissions for the same resource
const resourcePermissions = new Map<Resource, Set<AuthAction>>();
for (const scope of scopes) {
if (!scope) continue;
if (scope.startsWith('role:')) {
// Handle role scope
const roleStr = scope.substring(5).toUpperCase();
if (Object.values(Role).includes(roleStr as Role)) {
roles.push(roleStr as Role);
}
} else {
// Handle permission scope (potentially grouped)
const [resourcesPart, actionsPart] = scope.split(':');
if (!resourcesPart || !actionsPart) continue;
// Split grouped resources (docker+vms)
const resourceNames = resourcesPart.split('+');
// Parse actions
let actions: AuthAction[];
if (actionsPart === '*') {
// Wildcard: all CRUD actions
actions = [
AuthAction.CREATE_ANY,
AuthAction.READ_ANY,
AuthAction.UPDATE_ANY,
AuthAction.DELETE_ANY
];
} else {
// Split grouped actions (read_any+update_own)
const actionParts = actionsPart.split('+');
actions = actionParts
.map(actionStr => {
// Convert to AuthAction enum (e.g., "read_any" -> "READ_ANY")
const enumValue = actionStr.toUpperCase() as AuthAction;
return Object.values(AuthAction).includes(enumValue) ? enumValue : null;
})
.filter((action): action is AuthAction => action !== null);
}
// Add actions to each resource
for (const resourceName of resourceNames) {
const resourceUpper = resourceName.toUpperCase();
const resource = Object.values(Resource).find(r => r === resourceUpper) as Resource;
if (resource && actions.length > 0) {
if (!resourcePermissions.has(resource)) {
resourcePermissions.set(resource, new Set());
}
actions.forEach(action => resourcePermissions.get(resource)!.add(action));
}
}
}
}
// Convert map to array of permissions
const permissions = Array.from(resourcePermissions.entries()).map(([resource, actionsSet]) => ({
resource,
actions: Array.from(actionsSet)
}));
return { permissions, roles };
}
/**
* Convert scopes to form data structure with grouped permissions
*/
export function scopesToFormData(scopes: string[], name: string, description: string = ''): AuthorizationFormData {
const { permissions, roles } = decodeScopesToPermissions(scopes);
// Group permissions by their action sets for the form
const permissionGroups = new Map<string, { resources: Set<Resource>; actions: Set<AuthAction> }>();
for (const perm of permissions) {
// Create a key based on sorted actions to group resources
const actionKey = [...perm.actions].sort().join(',');
if (!permissionGroups.has(actionKey)) {
permissionGroups.set(actionKey, {
resources: new Set<Resource>(),
actions: new Set<AuthAction>(perm.actions),
});
}
permissionGroups.get(actionKey)!.resources.add(perm.resource);
}
// Convert to array format expected by form
const customPermissions = Array.from(permissionGroups.values()).map(group => ({
resources: Array.from(group.resources),
actions: Array.from(group.actions),
}));
return {
name,
description,
roles,
customPermissions: customPermissions.length > 0 ? customPermissions : [],
};
}
/**
* Generate authorization URL from params
*/
export function generateAuthorizationUrl(params: AuthorizationLinkParams): string {
const {
appName,
appDescription,
roles = [],
rawPermissions = [],
redirectUrl,
state
} = params;
// Compute redirectUrl with SSR safety
const computedRedirectUrl = redirectUrl || (
typeof window !== 'undefined'
? window.location.origin + '/api-key-created'
: '/api-key-created'
);
const scopes = encodePermissionsToScopes(roles, rawPermissions);
// Build URL parameters
const urlParams = new URLSearchParams({
name: appName,
redirect_uri: computedRedirectUrl,
scopes: scopes.join(','),
});
if (appDescription) {
urlParams.set('description', appDescription);
}
if (state) {
urlParams.set('state', state);
}
// Use current origin for the authorization URL with SSR safety
const baseUrl = typeof window !== 'undefined'
? `${window.location.origin}/Tools/ApiKeyAuthorize`
: '/Tools/ApiKeyAuthorize';
return `${baseUrl}?${urlParams.toString()}`;
}
/**
* Build callback URL with API key or error
*/
export function buildCallbackUrl(
redirectUri: string,
apiKey?: string,
error?: string,
state?: string
): string {
try {
const url = new URL(redirectUri);
if (apiKey) {
url.searchParams.set('api_key', apiKey);
}
if (error) {
url.searchParams.set('error', error);
}
if (state) {
url.searchParams.set('state', state);
}
return url.toString();
} catch {
throw new Error('Invalid redirect URI');
}
}
// Alias for backward compatibility
export const generateScopes = encodePermissionsToScopes;