mirror of
https://github.com/unraid/api.git
synced 2026-01-04 15:39:52 -06:00
<!-- 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 -->
311 lines
9.2 KiB
TypeScript
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;
|