@uploadista/server 0.0.18-beta.7 → 0.0.18-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Authorization Error Types
3
+ *
4
+ * Error classes for permission and authorization failures.
5
+ */
6
+
7
+ import { AdapterError } from "../error-types";
8
+
9
+ /**
10
+ * Authorization error - indicates the user lacks required permissions.
11
+ * Returns HTTP 403 Forbidden status.
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * if (!hasPermission(permissions, "engine:metrics")) {
16
+ * throw new AuthorizationError("engine:metrics");
17
+ * }
18
+ * ```
19
+ */
20
+ export class AuthorizationError extends AdapterError {
21
+ /**
22
+ * The permission that was required but not granted.
23
+ */
24
+ public readonly requiredPermission: string;
25
+
26
+ constructor(requiredPermission: string, message?: string) {
27
+ super(
28
+ message ?? `Permission denied: ${requiredPermission} required`,
29
+ 403,
30
+ "PERMISSION_DENIED",
31
+ );
32
+ this.name = "AuthorizationError";
33
+ this.requiredPermission = requiredPermission;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Authentication required error - indicates no authentication context.
39
+ * Returns HTTP 401 Unauthorized status.
40
+ *
41
+ * @example
42
+ * ```typescript
43
+ * if (!authContext) {
44
+ * throw new AuthenticationRequiredError();
45
+ * }
46
+ * ```
47
+ */
48
+ export class AuthenticationRequiredError extends AdapterError {
49
+ constructor(message = "Authentication required") {
50
+ super(message, 401, "AUTHENTICATION_REQUIRED");
51
+ this.name = "AuthenticationRequiredError";
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Organization mismatch error - indicates accessing a resource from another organization.
57
+ * Returns HTTP 403 Forbidden status.
58
+ *
59
+ * @example
60
+ * ```typescript
61
+ * if (resource.organizationId !== clientId) {
62
+ * throw new OrganizationMismatchError();
63
+ * }
64
+ * ```
65
+ */
66
+ export class OrganizationMismatchError extends AdapterError {
67
+ constructor(message = "Access denied: resource belongs to another organization") {
68
+ super(message, 403, "ORGANIZATION_MISMATCH");
69
+ this.name = "OrganizationMismatchError";
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Quota exceeded error - indicates usage quota has been exceeded.
75
+ * Returns HTTP 402 Payment Required status.
76
+ *
77
+ * @example
78
+ * ```typescript
79
+ * if (usage > quota) {
80
+ * throw new QuotaExceededError("Storage quota exceeded");
81
+ * }
82
+ * ```
83
+ */
84
+ export class QuotaExceededError extends AdapterError {
85
+ constructor(message = "Quota exceeded", code = "QUOTA_EXCEEDED") {
86
+ super(message, 402, code);
87
+ this.name = "QuotaExceededError";
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Creates a standardized error response body for AuthorizationError.
93
+ * Includes the required permission in the response.
94
+ *
95
+ * @param error - The AuthorizationError to format
96
+ * @returns Standardized error response body
97
+ */
98
+ export const createAuthorizationErrorResponseBody = (
99
+ error: AuthorizationError,
100
+ ) => ({
101
+ error: error.message,
102
+ code: error.errorCode,
103
+ requiredPermission: error.requiredPermission,
104
+ timestamp: new Date().toISOString(),
105
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Permissions Module
3
+ *
4
+ * Exports all permission-related types, constants, and utilities.
5
+ */
6
+
7
+ export * from "./types";
8
+ export * from "./matcher";
9
+ export * from "./errors";
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Permission Matching Logic
3
+ *
4
+ * Implements permission matching with support for:
5
+ * - Exact match: `engine:health` matches `engine:health`
6
+ * - Wildcard match: `engine:*` matches `engine:health`, `engine:metrics`, etc.
7
+ * - Hierarchical match: `engine:dlq` implies `engine:dlq:read` and `engine:dlq:write`
8
+ */
9
+
10
+ import { PERMISSION_HIERARCHY } from "./types";
11
+
12
+ /**
13
+ * Checks if a granted permission matches a required permission.
14
+ *
15
+ * @param granted - The permission that has been granted to the user
16
+ * @param required - The permission that is required for the operation
17
+ * @returns true if the granted permission satisfies the required permission
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * matchesPermission("engine:*", "engine:health") // true (wildcard)
22
+ * matchesPermission("engine:health", "engine:health") // true (exact)
23
+ * matchesPermission("engine:dlq", "engine:dlq:read") // true (hierarchical)
24
+ * matchesPermission("flow:execute", "engine:health") // false
25
+ * ```
26
+ */
27
+ export const matchesPermission = (
28
+ granted: string,
29
+ required: string,
30
+ ): boolean => {
31
+ // Exact match
32
+ if (granted === required) {
33
+ return true;
34
+ }
35
+
36
+ // Wildcard match: `engine:*` matches `engine:health`
37
+ if (granted.endsWith(":*")) {
38
+ const prefix = granted.slice(0, -1); // Remove the `*`, keep the `:`
39
+ if (required.startsWith(prefix)) {
40
+ return true;
41
+ }
42
+ }
43
+
44
+ // Hierarchical match: `engine:dlq` implies `engine:dlq:read`
45
+ const impliedPermissions = PERMISSION_HIERARCHY[granted];
46
+ if (impliedPermissions?.includes(required)) {
47
+ return true;
48
+ }
49
+
50
+ return false;
51
+ };
52
+
53
+ /**
54
+ * Checks if any of the granted permissions satisfy the required permission.
55
+ *
56
+ * @param grantedPermissions - Array of permissions granted to the user
57
+ * @param required - The permission that is required for the operation
58
+ * @returns true if any granted permission satisfies the required permission
59
+ *
60
+ * @example
61
+ * ```typescript
62
+ * hasPermission(["flow:*", "upload:create"], "flow:execute") // true
63
+ * hasPermission(["upload:create"], "flow:execute") // false
64
+ * ```
65
+ */
66
+ export const hasPermission = (
67
+ grantedPermissions: readonly string[],
68
+ required: string,
69
+ ): boolean => {
70
+ return grantedPermissions.some((granted) =>
71
+ matchesPermission(granted, required),
72
+ );
73
+ };
74
+
75
+ /**
76
+ * Checks if any of the granted permissions satisfy any of the required permissions.
77
+ *
78
+ * @param grantedPermissions - Array of permissions granted to the user
79
+ * @param requiredPermissions - Array of permissions, any of which would be sufficient
80
+ * @returns true if any granted permission satisfies any required permission
81
+ *
82
+ * @example
83
+ * ```typescript
84
+ * hasAnyPermission(["upload:create"], ["flow:execute", "upload:create"]) // true
85
+ * hasAnyPermission(["upload:read"], ["flow:execute", "upload:create"]) // false
86
+ * ```
87
+ */
88
+ export const hasAnyPermission = (
89
+ grantedPermissions: readonly string[],
90
+ requiredPermissions: readonly string[],
91
+ ): boolean => {
92
+ return requiredPermissions.some((required) =>
93
+ hasPermission(grantedPermissions, required),
94
+ );
95
+ };
96
+
97
+ /**
98
+ * Checks if all of the required permissions are satisfied.
99
+ *
100
+ * @param grantedPermissions - Array of permissions granted to the user
101
+ * @param requiredPermissions - Array of permissions, all of which must be satisfied
102
+ * @returns true if all required permissions are satisfied
103
+ *
104
+ * @example
105
+ * ```typescript
106
+ * hasAllPermissions(["flow:*", "upload:*"], ["flow:execute", "upload:create"]) // true
107
+ * hasAllPermissions(["flow:execute"], ["flow:execute", "upload:create"]) // false
108
+ * ```
109
+ */
110
+ export const hasAllPermissions = (
111
+ grantedPermissions: readonly string[],
112
+ requiredPermissions: readonly string[],
113
+ ): boolean => {
114
+ return requiredPermissions.every((required) =>
115
+ hasPermission(grantedPermissions, required),
116
+ );
117
+ };
118
+
119
+ /**
120
+ * Expands a permission to include all implied permissions.
121
+ * Useful for display or audit purposes.
122
+ *
123
+ * @param permission - The permission to expand
124
+ * @returns Array of the permission and all implied permissions
125
+ *
126
+ * @example
127
+ * ```typescript
128
+ * expandPermission("engine:dlq") // ["engine:dlq", "engine:dlq:read", "engine:dlq:write"]
129
+ * expandPermission("engine:health") // ["engine:health"]
130
+ * ```
131
+ */
132
+ export const expandPermission = (permission: string): string[] => {
133
+ const result = [permission];
134
+ const implied = PERMISSION_HIERARCHY[permission];
135
+ if (implied) {
136
+ result.push(...implied);
137
+ }
138
+ return result;
139
+ };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Permission Types and Constants
3
+ *
4
+ * Defines the permission model for fine-grained access control in the uploadista engine.
5
+ * Permissions follow a hierarchical format: `resource:action` with support for wildcards.
6
+ */
7
+
8
+ // ============================================================================
9
+ // Engine Permissions - Admin operations
10
+ // ============================================================================
11
+
12
+ /**
13
+ * Engine permissions for administrative operations.
14
+ * These control access to health, readiness, metrics, and DLQ endpoints.
15
+ */
16
+ export const ENGINE_PERMISSIONS = {
17
+ /** Full admin access to all engine operations */
18
+ ALL: "engine:*",
19
+ /** Access health endpoint */
20
+ HEALTH: "engine:health",
21
+ /** Access readiness endpoint */
22
+ READINESS: "engine:readiness",
23
+ /** Access metrics endpoint */
24
+ METRICS: "engine:metrics",
25
+ /** Full DLQ access (implies read and write) */
26
+ DLQ: "engine:dlq",
27
+ /** Read DLQ entries */
28
+ DLQ_READ: "engine:dlq:read",
29
+ /** Retry/delete DLQ entries */
30
+ DLQ_WRITE: "engine:dlq:write",
31
+ } as const;
32
+
33
+ // ============================================================================
34
+ // Flow Permissions - Flow execution operations
35
+ // ============================================================================
36
+
37
+ /**
38
+ * Flow permissions for flow execution operations.
39
+ */
40
+ export const FLOW_PERMISSIONS = {
41
+ /** Full access to all flow operations */
42
+ ALL: "flow:*",
43
+ /** Execute flows */
44
+ EXECUTE: "flow:execute",
45
+ /** Cancel running flows */
46
+ CANCEL: "flow:cancel",
47
+ /** Check flow status */
48
+ STATUS: "flow:status",
49
+ } as const;
50
+
51
+ // ============================================================================
52
+ // Upload Permissions - File upload operations
53
+ // ============================================================================
54
+
55
+ /**
56
+ * Upload permissions for file upload operations.
57
+ */
58
+ export const UPLOAD_PERMISSIONS = {
59
+ /** Full access to all upload operations */
60
+ ALL: "upload:*",
61
+ /** Create uploads */
62
+ CREATE: "upload:create",
63
+ /** Read upload status */
64
+ READ: "upload:read",
65
+ /** Cancel uploads */
66
+ CANCEL: "upload:cancel",
67
+ } as const;
68
+
69
+ // ============================================================================
70
+ // Combined Permissions Object
71
+ // ============================================================================
72
+
73
+ /**
74
+ * All available permissions organized by category.
75
+ *
76
+ * @example
77
+ * ```typescript
78
+ * import { PERMISSIONS } from "@uploadista/server";
79
+ *
80
+ * const adminPermissions = [PERMISSIONS.ENGINE.ALL];
81
+ * const userPermissions = [PERMISSIONS.FLOW.ALL, PERMISSIONS.UPLOAD.ALL];
82
+ * ```
83
+ */
84
+ export const PERMISSIONS = {
85
+ ENGINE: ENGINE_PERMISSIONS,
86
+ FLOW: FLOW_PERMISSIONS,
87
+ UPLOAD: UPLOAD_PERMISSIONS,
88
+ } as const;
89
+
90
+ // ============================================================================
91
+ // Permission Type Definitions
92
+ // ============================================================================
93
+
94
+ /** All engine permission strings */
95
+ export type EnginePermission =
96
+ (typeof ENGINE_PERMISSIONS)[keyof typeof ENGINE_PERMISSIONS];
97
+
98
+ /** All flow permission strings */
99
+ export type FlowPermission =
100
+ (typeof FLOW_PERMISSIONS)[keyof typeof FLOW_PERMISSIONS];
101
+
102
+ /** All upload permission strings */
103
+ export type UploadPermission =
104
+ (typeof UPLOAD_PERMISSIONS)[keyof typeof UPLOAD_PERMISSIONS];
105
+
106
+ /**
107
+ * Union type of all valid permission strings.
108
+ * Includes standard permissions and allows custom permissions via string.
109
+ */
110
+ export type Permission =
111
+ | EnginePermission
112
+ | FlowPermission
113
+ | UploadPermission
114
+ | (string & {}); // Allow custom permissions while maintaining autocomplete
115
+
116
+ /**
117
+ * Predefined permission sets for common use cases.
118
+ */
119
+ export const PERMISSION_SETS = {
120
+ /** Full admin access - all engine, flow, and upload permissions */
121
+ ADMIN: [ENGINE_PERMISSIONS.ALL] as const,
122
+
123
+ /** Organization owner - all flow and upload permissions */
124
+ ORGANIZATION_OWNER: [
125
+ FLOW_PERMISSIONS.ALL,
126
+ UPLOAD_PERMISSIONS.ALL,
127
+ ] as const,
128
+
129
+ /** Organization member - same as owner for now */
130
+ ORGANIZATION_MEMBER: [
131
+ FLOW_PERMISSIONS.ALL,
132
+ UPLOAD_PERMISSIONS.ALL,
133
+ ] as const,
134
+
135
+ /** API key - limited to execute flows and create uploads */
136
+ API_KEY: [
137
+ FLOW_PERMISSIONS.EXECUTE,
138
+ UPLOAD_PERMISSIONS.CREATE,
139
+ ] as const,
140
+ } as const;
141
+
142
+ /**
143
+ * Hierarchical permission relationships.
144
+ * When a parent permission is granted, all child permissions are implied.
145
+ */
146
+ export const PERMISSION_HIERARCHY: Record<string, readonly string[]> = {
147
+ [ENGINE_PERMISSIONS.DLQ]: [
148
+ ENGINE_PERMISSIONS.DLQ_READ,
149
+ ENGINE_PERMISSIONS.DLQ_WRITE,
150
+ ],
151
+ } as const;
package/src/service.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import { Context, Effect, Layer } from "effect";
2
2
  import type { AuthContext } from "./types";
3
+ import {
4
+ hasPermission as matchHasPermission,
5
+ hasAnyPermission as matchHasAnyPermission,
6
+ } from "./permissions/matcher";
7
+ import { AuthorizationError, AuthenticationRequiredError } from "./permissions/errors";
3
8
 
4
9
  /**
5
10
  * Authentication Context Service
@@ -39,10 +44,68 @@ export class AuthContextService extends Context.Tag("AuthContextService")<
39
44
 
40
45
  /**
41
46
  * Check if the current client has a specific permission.
47
+ * Supports exact match, wildcard match, and hierarchical match.
42
48
  * Returns false if no authentication context or permission not found.
49
+ *
50
+ * @example
51
+ * ```typescript
52
+ * // Exact match
53
+ * yield* authService.hasPermission("engine:health")
54
+ *
55
+ * // Wildcard: user with "engine:*" will match "engine:health"
56
+ * yield* authService.hasPermission("engine:health")
57
+ *
58
+ * // Hierarchical: user with "engine:dlq" will match "engine:dlq:read"
59
+ * yield* authService.hasPermission("engine:dlq:read")
60
+ * ```
43
61
  */
44
62
  readonly hasPermission: (permission: string) => Effect.Effect<boolean>;
45
63
 
64
+ /**
65
+ * Check if the current client has any of the specified permissions.
66
+ * Returns true if at least one permission is granted.
67
+ */
68
+ readonly hasAnyPermission: (
69
+ permissions: readonly string[],
70
+ ) => Effect.Effect<boolean>;
71
+
72
+ /**
73
+ * Require a specific permission, failing with AuthorizationError if not granted.
74
+ * Use this when you want to fail fast on missing permissions.
75
+ *
76
+ * @throws AuthorizationError if permission is not granted
77
+ * @throws AuthenticationRequiredError if no auth context
78
+ *
79
+ * @example
80
+ * ```typescript
81
+ * const protectedHandler = Effect.gen(function* () {
82
+ * const authService = yield* AuthContextService;
83
+ * yield* authService.requirePermission("engine:metrics");
84
+ * // Only reaches here if permission is granted
85
+ * return yield* getMetrics();
86
+ * });
87
+ * ```
88
+ */
89
+ readonly requirePermission: (
90
+ permission: string,
91
+ ) => Effect.Effect<void, AuthorizationError | AuthenticationRequiredError>;
92
+
93
+ /**
94
+ * Require authentication, failing with AuthenticationRequiredError if not authenticated.
95
+ *
96
+ * @throws AuthenticationRequiredError if no auth context
97
+ */
98
+ readonly requireAuthentication: () => Effect.Effect<
99
+ AuthContext,
100
+ AuthenticationRequiredError
101
+ >;
102
+
103
+ /**
104
+ * Get all permissions granted to the current client.
105
+ * Returns empty array if no authentication context or no permissions.
106
+ */
107
+ readonly getPermissions: () => Effect.Effect<readonly string[]>;
108
+
46
109
  /**
47
110
  * Get the full authentication context if available.
48
111
  * Returns null if no authentication context is available.
@@ -60,14 +123,49 @@ export class AuthContextService extends Context.Tag("AuthContextService")<
60
123
  */
61
124
  export const AuthContextServiceLive = (
62
125
  authContext: AuthContext | null,
63
- ): Layer.Layer<AuthContextService> =>
64
- Layer.succeed(AuthContextService, {
126
+ ): Layer.Layer<AuthContextService> => {
127
+ const permissions = authContext?.permissions ?? [];
128
+
129
+ return Layer.succeed(AuthContextService, {
65
130
  getClientId: () => Effect.succeed(authContext?.clientId ?? null),
131
+
66
132
  getMetadata: () => Effect.succeed(authContext?.metadata ?? {}),
133
+
67
134
  hasPermission: (permission: string) =>
68
- Effect.succeed(authContext?.permissions?.includes(permission) ?? false),
135
+ Effect.succeed(matchHasPermission(permissions, permission)),
136
+
137
+ hasAnyPermission: (requiredPermissions: readonly string[]) =>
138
+ Effect.succeed(matchHasAnyPermission(permissions, requiredPermissions)),
139
+
140
+ requirePermission: (permission: string) =>
141
+ Effect.gen(function* () {
142
+ if (!authContext) {
143
+ yield* Effect.logDebug(
144
+ `[Auth] Permission check failed: authentication required for '${permission}'`,
145
+ );
146
+ return yield* Effect.fail(new AuthenticationRequiredError());
147
+ }
148
+ if (!matchHasPermission(permissions, permission)) {
149
+ yield* Effect.logDebug(
150
+ `[Auth] Permission denied: '${permission}' for client '${authContext.clientId}'`,
151
+ );
152
+ return yield* Effect.fail(new AuthorizationError(permission));
153
+ }
154
+ yield* Effect.logDebug(
155
+ `[Auth] Permission granted: '${permission}' for client '${authContext.clientId}'`,
156
+ );
157
+ }),
158
+
159
+ requireAuthentication: () =>
160
+ authContext
161
+ ? Effect.succeed(authContext)
162
+ : Effect.fail(new AuthenticationRequiredError()),
163
+
164
+ getPermissions: () => Effect.succeed(permissions),
165
+
69
166
  getAuthContext: () => Effect.succeed(authContext),
70
167
  });
168
+ };
71
169
 
72
170
  /**
73
171
  * No-auth implementation of AuthContextService.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Usage Hooks Module
3
+ *
4
+ * Exports all usage hook types and services.
5
+ */
6
+
7
+ export * from "./types";
8
+ export * from "./service";