@lbstack/accessx 0.1.0 → 0.3.0
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.
- package/dist/src/core/access-control.d.ts +29 -10
- package/dist/src/core/access-control.js +63 -78
- package/dist/src/core/engine.d.ts +30 -0
- package/dist/src/core/engine.js +176 -0
- package/dist/src/core/index.d.ts +2 -0
- package/dist/src/core/index.js +2 -0
- package/dist/src/core/ui/access-control-ui.d.ts +13 -7
- package/dist/src/core/ui/access-control-ui.js +23 -7
- package/package.json +33 -30
- package/readme.md +108 -72
|
@@ -1,23 +1,42 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Resource } from "../types";
|
|
2
3
|
export declare function createAccess<const R extends readonly string[], const A extends readonly string[], const Res extends readonly Resource[]>(config: {
|
|
3
4
|
roles: R;
|
|
4
5
|
actions: A;
|
|
5
6
|
resources: Res;
|
|
6
7
|
}): {
|
|
8
|
+
useCan: (role: R[number] | R[number][], permission: string, context?: any) => boolean;
|
|
9
|
+
Can: ({ role, permission, context, children, }: {
|
|
10
|
+
role: R[number] | R[number][];
|
|
11
|
+
permission: string;
|
|
12
|
+
context?: any;
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
}) => import("react/jsx-runtime").JSX.Element | null;
|
|
15
|
+
usePermissions: (source: R[number] | string[] | (() => Promise<string[]>)) => {
|
|
16
|
+
permissions: string[];
|
|
17
|
+
loading: boolean;
|
|
18
|
+
refresh: () => Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
refresh: (role?: R[number] | undefined) => Promise<void>;
|
|
21
|
+
canDo: (granted: any[], permission: string, context?: any) => boolean;
|
|
7
22
|
roles: R;
|
|
8
23
|
actions: A;
|
|
9
24
|
resources: Res;
|
|
10
25
|
permissions: {
|
|
11
|
-
key: "
|
|
26
|
+
key: import("./engine").PermissionKey<Res, A>;
|
|
12
27
|
resource: Resource;
|
|
13
28
|
action: string;
|
|
14
29
|
}[];
|
|
15
|
-
permissionKeys: ("
|
|
16
|
-
allow: (role: R[number], permission: ("
|
|
17
|
-
can: (role: R[number]
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
assignPermissions: (role: R[number], perms: ("
|
|
22
|
-
|
|
30
|
+
permissionKeys: import("./engine").PermissionKey<Res, A>[];
|
|
31
|
+
allow: (role: R[number], permission: import("./engine").PermissionKey<Res, A> | import("./engine").PermissionKey<Res, A>[], condition?: import("../types").ConditionFn) => void;
|
|
32
|
+
can: (role: R[number] | R[number][], permission: import("./engine").PermissionKey<Res, A>, context?: any) => boolean;
|
|
33
|
+
resolvePermissions: (role: R[number]) => import("./engine").PermissionKey<Res, A>[];
|
|
34
|
+
normalizePermissions: (raw: string[]) => import("./engine").PermissionKey<Res, A>[];
|
|
35
|
+
getRolePermissions: (role: R[number]) => import("./engine").PermissionKey<Res, A>[];
|
|
36
|
+
assignPermissions: (role: R[number], perms: import("./engine").PermissionKey<Res, A> | import("./engine").PermissionKey<Res, A>[] | (() => Promise<import("./engine").PermissionKey<Res, A> | import("./engine").PermissionKey<Res, A>[]>), options?: import("../types").ConditionFn | {
|
|
37
|
+
condition?: import("../types").ConditionFn;
|
|
38
|
+
invalidateKey?: string | (() => Promise<string>) | undefined;
|
|
39
|
+
interval?: number;
|
|
40
|
+
} | undefined) => Promise<void>;
|
|
41
|
+
subscribe: (cb: () => void) => () => void;
|
|
23
42
|
};
|
|
@@ -1,85 +1,70 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { createEngine } from "./engine";
|
|
4
|
+
import { createAccessUI } from "./ui/access-control-ui";
|
|
1
5
|
export function createAccess(config) {
|
|
2
|
-
const
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
6
|
+
const engine = createEngine(config);
|
|
7
|
+
const { canDo } = createAccessUI();
|
|
8
|
+
/**
|
|
9
|
+
* Hook to manage permissions from various sources (static, dynamic, or engine-bound)
|
|
10
|
+
*/
|
|
11
|
+
function usePermissions(source) {
|
|
12
|
+
const [perms, setPerms] = React.useState([]);
|
|
13
|
+
const [loading, setLoading] = React.useState(true);
|
|
14
|
+
const fetch = React.useCallback(async () => {
|
|
15
|
+
setLoading(true);
|
|
16
|
+
try {
|
|
17
|
+
if (typeof source === "function") {
|
|
18
|
+
const res = await source();
|
|
19
|
+
setPerms(res);
|
|
20
|
+
}
|
|
21
|
+
else if (Array.isArray(source)) {
|
|
22
|
+
setPerms(source);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
// Engine-bound role
|
|
26
|
+
setPerms(engine.getRolePermissions(source));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
setLoading(false);
|
|
31
|
+
}
|
|
32
|
+
}, [source]);
|
|
33
|
+
React.useEffect(() => {
|
|
34
|
+
fetch();
|
|
35
|
+
// If it's a role (string provided but not an array), subscribe to global refreshes
|
|
36
|
+
if (typeof source === "string" && !Array.isArray(source)) {
|
|
37
|
+
return engine.subscribe(() => {
|
|
38
|
+
setPerms(engine.getRolePermissions(source));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}, [fetch, source]);
|
|
42
|
+
return { permissions: perms, loading, refresh: fetch };
|
|
16
43
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Pre-bound UI components
|
|
46
|
+
*/
|
|
47
|
+
function useCan(role, permission, context) {
|
|
48
|
+
const [allowed, setAllowed] = React.useState(() => engine.can(role, permission, context));
|
|
49
|
+
React.useEffect(() => {
|
|
50
|
+
setAllowed(engine.can(role, permission, context));
|
|
51
|
+
return engine.subscribe(() => {
|
|
52
|
+
setAllowed(engine.can(role, permission, context));
|
|
53
|
+
});
|
|
54
|
+
}, [role, permission, context]);
|
|
55
|
+
return allowed;
|
|
20
56
|
}
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
function resolvePermissions(role) {
|
|
25
|
-
return getRolePermissions(role);
|
|
26
|
-
}
|
|
27
|
-
function hasPermission(granted, required, context) {
|
|
28
|
-
// This is a static check for local lists.
|
|
29
|
-
// For ABAC, we need the actual definition with conditions.
|
|
30
|
-
// If 'granted' is just string[], we can't check conditions unless we have the role definition.
|
|
31
|
-
if (granted.includes("*"))
|
|
32
|
-
return true;
|
|
33
|
-
if (granted.includes(required))
|
|
34
|
-
return true;
|
|
35
|
-
const [resource] = required.split(":");
|
|
36
|
-
if (granted.includes(`${resource}:*`))
|
|
37
|
-
return true;
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
40
|
-
// New: Role-aware permission check (supports ABAC)
|
|
41
|
-
function can(role, permission, context) {
|
|
42
|
-
const permsMap = rolePermissions.get(role);
|
|
43
|
-
if (!permsMap)
|
|
44
|
-
return false;
|
|
45
|
-
// 1. Global wildcard
|
|
46
|
-
if (permsMap.has("*")) {
|
|
47
|
-
const condition = permsMap.get("*");
|
|
48
|
-
if (condition(context))
|
|
49
|
-
return true;
|
|
50
|
-
}
|
|
51
|
-
// 2. Direct match
|
|
52
|
-
if (permsMap.has(permission)) {
|
|
53
|
-
const condition = permsMap.get(permission);
|
|
54
|
-
if (condition(context))
|
|
55
|
-
return true;
|
|
56
|
-
}
|
|
57
|
-
// 3. Resource wildcard
|
|
58
|
-
const [resource] = permission.split(":");
|
|
59
|
-
const resourceWildcard = `${resource}:*`;
|
|
60
|
-
if (permsMap.has(resourceWildcard)) {
|
|
61
|
-
const condition = permsMap.get(resourceWildcard);
|
|
62
|
-
if (condition(context))
|
|
63
|
-
return true;
|
|
64
|
-
}
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
function normalizePermissions(raw) {
|
|
68
|
-
const valid = new Set(permissionKeys);
|
|
69
|
-
return raw.filter((p) => valid.has(p));
|
|
57
|
+
function Can({ role, permission, context, children, }) {
|
|
58
|
+
const allowed = useCan(role, permission, context);
|
|
59
|
+
return allowed ? _jsx(_Fragment, { children: children }) : null;
|
|
70
60
|
}
|
|
71
61
|
return {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
hasPermission, // Static list check (Legacy/Simple)
|
|
80
|
-
resolvePermissions,
|
|
81
|
-
normalizePermissions,
|
|
82
|
-
assignPermissions,
|
|
83
|
-
getRolePermissions,
|
|
62
|
+
...engine,
|
|
63
|
+
useCan,
|
|
64
|
+
Can,
|
|
65
|
+
usePermissions,
|
|
66
|
+
refresh: engine.refresh,
|
|
67
|
+
// Keep legacy check just in case
|
|
68
|
+
canDo,
|
|
84
69
|
};
|
|
85
70
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ConditionFn, Resource } from "../types";
|
|
2
|
+
export type PermissionKey<Res extends readonly Resource[], A extends readonly string[]> = `${Res[number]["key"]}:${A[number]}` | `${Res[number]["key"]}:*` | "*";
|
|
3
|
+
export declare function createEngine<const R extends readonly string[], const A extends readonly string[], const Res extends readonly Resource[]>(config: {
|
|
4
|
+
roles: R;
|
|
5
|
+
actions: A;
|
|
6
|
+
resources: Res;
|
|
7
|
+
}): {
|
|
8
|
+
roles: R;
|
|
9
|
+
actions: A;
|
|
10
|
+
resources: Res;
|
|
11
|
+
permissions: {
|
|
12
|
+
key: PermissionKey<Res, A>;
|
|
13
|
+
resource: Resource;
|
|
14
|
+
action: string;
|
|
15
|
+
}[];
|
|
16
|
+
permissionKeys: PermissionKey<Res, A>[];
|
|
17
|
+
allow: (role: R[number], permission: PermissionKey<Res, A> | PermissionKey<Res, A>[], condition?: ConditionFn) => void;
|
|
18
|
+
can: (role: R[number] | R[number][], permission: PermissionKey<Res, A>, context?: any) => boolean;
|
|
19
|
+
resolvePermissions: (role: R[number]) => PermissionKey<Res, A>[];
|
|
20
|
+
normalizePermissions: (raw: string[]) => PermissionKey<Res, A>[];
|
|
21
|
+
getRolePermissions: (role: R[number]) => PermissionKey<Res, A>[];
|
|
22
|
+
assignPermissions: (role: R[number], perms: PermissionKey<Res, A> | PermissionKey<Res, A>[] | (() => Promise<PermissionKey<Res, A> | PermissionKey<Res, A>[]>), options?: ConditionFn | {
|
|
23
|
+
condition?: ConditionFn;
|
|
24
|
+
invalidateKey?: string | (() => Promise<string>);
|
|
25
|
+
interval?: number;
|
|
26
|
+
}) => Promise<void>;
|
|
27
|
+
refresh: (role?: R[number]) => Promise<void>;
|
|
28
|
+
subscribe: (cb: () => void) => () => void;
|
|
29
|
+
};
|
|
30
|
+
export type Engine = ReturnType<typeof createEngine>;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
export function createEngine(config) {
|
|
2
|
+
const roleAssignments = new Map();
|
|
3
|
+
const listeners = new Set();
|
|
4
|
+
function subscribe(cb) {
|
|
5
|
+
listeners.add(cb);
|
|
6
|
+
return () => {
|
|
7
|
+
listeners.delete(cb);
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function notify() {
|
|
11
|
+
listeners.forEach((cb) => cb());
|
|
12
|
+
}
|
|
13
|
+
const permissions = config.resources.flatMap((resource) => config.actions.map((action) => ({
|
|
14
|
+
key: `${resource.key}:${action}`,
|
|
15
|
+
resource,
|
|
16
|
+
action,
|
|
17
|
+
})));
|
|
18
|
+
const permissionKeys = permissions.map((p) => p.key);
|
|
19
|
+
async function assignPermissions(role, perms, options) {
|
|
20
|
+
const condition = typeof options === "function" ? options : options?.condition;
|
|
21
|
+
const invalidateKey = typeof options === "object" ? options?.invalidateKey : undefined;
|
|
22
|
+
const interval = typeof options === "object" ? options?.interval : undefined;
|
|
23
|
+
const assignment = {
|
|
24
|
+
permissions: new Map(),
|
|
25
|
+
permsFetcher: typeof perms === "function" ? perms : undefined,
|
|
26
|
+
invalidateKeyFetcher: invalidateKey,
|
|
27
|
+
interval: interval,
|
|
28
|
+
condition: condition,
|
|
29
|
+
};
|
|
30
|
+
const updateFn = async (newKey) => {
|
|
31
|
+
let list = [];
|
|
32
|
+
if (typeof perms === "function") {
|
|
33
|
+
const _permissions = await perms();
|
|
34
|
+
list = Array.isArray(_permissions)
|
|
35
|
+
? _permissions
|
|
36
|
+
: [_permissions];
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
list = Array.isArray(perms) ? perms : [perms];
|
|
40
|
+
}
|
|
41
|
+
assignment.permissions.clear();
|
|
42
|
+
list.forEach((p) => assignment.permissions.set(p, assignment.condition || (() => true)));
|
|
43
|
+
if (newKey !== undefined) {
|
|
44
|
+
assignment.lastInvalidateKey = newKey;
|
|
45
|
+
}
|
|
46
|
+
else if (assignment.invalidateKeyFetcher) {
|
|
47
|
+
assignment.lastInvalidateKey =
|
|
48
|
+
typeof assignment.invalidateKeyFetcher === "function"
|
|
49
|
+
? await assignment.invalidateKeyFetcher()
|
|
50
|
+
: assignment.invalidateKeyFetcher;
|
|
51
|
+
}
|
|
52
|
+
notify();
|
|
53
|
+
};
|
|
54
|
+
await updateFn();
|
|
55
|
+
if (!roleAssignments.has(role))
|
|
56
|
+
roleAssignments.set(role, []);
|
|
57
|
+
roleAssignments.get(role).push(assignment);
|
|
58
|
+
if (interval && interval > 0) {
|
|
59
|
+
assignment.timer = setInterval(async () => {
|
|
60
|
+
const currentKey = assignment.invalidateKeyFetcher
|
|
61
|
+
? typeof assignment.invalidateKeyFetcher === "function"
|
|
62
|
+
? await assignment.invalidateKeyFetcher()
|
|
63
|
+
: assignment.invalidateKeyFetcher
|
|
64
|
+
: undefined;
|
|
65
|
+
if (!assignment.invalidateKeyFetcher ||
|
|
66
|
+
currentKey !== assignment.lastInvalidateKey) {
|
|
67
|
+
await updateFn(currentKey);
|
|
68
|
+
}
|
|
69
|
+
}, interval);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function refresh(role) {
|
|
73
|
+
const roles = role ? [role] : Array.from(roleAssignments.keys());
|
|
74
|
+
for (const r of roles) {
|
|
75
|
+
const assignments = roleAssignments.get(r) || [];
|
|
76
|
+
for (const a of assignments) {
|
|
77
|
+
if (!a.permsFetcher)
|
|
78
|
+
continue;
|
|
79
|
+
const localUpdate = async (newKey) => {
|
|
80
|
+
const _permissions = await a.permsFetcher();
|
|
81
|
+
const list = Array.isArray(_permissions)
|
|
82
|
+
? _permissions
|
|
83
|
+
: [_permissions];
|
|
84
|
+
a.permissions.clear();
|
|
85
|
+
list.forEach((p) => a.permissions.set(p, a.condition || (() => true)));
|
|
86
|
+
if (newKey !== undefined) {
|
|
87
|
+
a.lastInvalidateKey = newKey;
|
|
88
|
+
}
|
|
89
|
+
else if (a.invalidateKeyFetcher) {
|
|
90
|
+
a.lastInvalidateKey =
|
|
91
|
+
typeof a.invalidateKeyFetcher === "function"
|
|
92
|
+
? await a.invalidateKeyFetcher()
|
|
93
|
+
: a.invalidateKeyFetcher;
|
|
94
|
+
}
|
|
95
|
+
notify();
|
|
96
|
+
};
|
|
97
|
+
if (a.invalidateKeyFetcher) {
|
|
98
|
+
const currentKey = typeof a.invalidateKeyFetcher === "function"
|
|
99
|
+
? await a.invalidateKeyFetcher()
|
|
100
|
+
: a.invalidateKeyFetcher;
|
|
101
|
+
if (currentKey !== a.lastInvalidateKey) {
|
|
102
|
+
await localUpdate(currentKey);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
await localUpdate();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function getRolePermissions(role) {
|
|
112
|
+
const assignments = roleAssignments.get(role) || [];
|
|
113
|
+
const allPerms = new Set();
|
|
114
|
+
assignments.forEach((a) => {
|
|
115
|
+
Array.from(a.permissions.keys()).forEach((p) => allPerms.add(p));
|
|
116
|
+
});
|
|
117
|
+
return Array.from(allPerms);
|
|
118
|
+
}
|
|
119
|
+
function allow(role, permission, condition) {
|
|
120
|
+
assignPermissions(role, permission, condition);
|
|
121
|
+
}
|
|
122
|
+
function resolvePermissions(role) {
|
|
123
|
+
return getRolePermissions(role);
|
|
124
|
+
}
|
|
125
|
+
function can(role, permission, context) {
|
|
126
|
+
const roles = Array.isArray(role) ? role : [role];
|
|
127
|
+
return roles.some((r) => {
|
|
128
|
+
const assignments = roleAssignments.get(r);
|
|
129
|
+
if (!assignments)
|
|
130
|
+
return false;
|
|
131
|
+
return assignments.some((assignment) => {
|
|
132
|
+
const permsMap = assignment.permissions;
|
|
133
|
+
// 1. Global wildcard
|
|
134
|
+
if (permsMap.has("*")) {
|
|
135
|
+
const condition = permsMap.get("*");
|
|
136
|
+
if (condition(context))
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
// 2. Direct match
|
|
140
|
+
if (permsMap.has(permission)) {
|
|
141
|
+
const condition = permsMap.get(permission);
|
|
142
|
+
if (condition(context))
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
// 3. Resource wildcard
|
|
146
|
+
const [resource] = permission.split(":");
|
|
147
|
+
const resourceWildcard = `${resource}:*`;
|
|
148
|
+
if (permsMap.has(resourceWildcard)) {
|
|
149
|
+
const condition = permsMap.get(resourceWildcard);
|
|
150
|
+
if (condition(context))
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function normalizePermissions(raw) {
|
|
158
|
+
const valid = new Set(permissionKeys);
|
|
159
|
+
return raw.filter((p) => valid.has(p));
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
roles: config.roles,
|
|
163
|
+
actions: config.actions,
|
|
164
|
+
resources: config.resources,
|
|
165
|
+
permissions,
|
|
166
|
+
permissionKeys,
|
|
167
|
+
allow,
|
|
168
|
+
can,
|
|
169
|
+
resolvePermissions,
|
|
170
|
+
normalizePermissions,
|
|
171
|
+
getRolePermissions,
|
|
172
|
+
assignPermissions,
|
|
173
|
+
refresh,
|
|
174
|
+
subscribe,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
export interface CanProps {
|
|
3
|
+
permission: string;
|
|
4
|
+
context?: any;
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
}
|
|
7
|
+
export declare function createAccessUI(engine?: {
|
|
8
|
+
can: (role: any, permission: any, context?: any) => boolean;
|
|
9
|
+
}): {
|
|
10
|
+
useCan: (grantedOrPermission: any[] | string, permissionOrContext?: string | any, maybeContext?: any) => any;
|
|
5
11
|
Can: ({ permissions, permission, context, children, }: {
|
|
6
|
-
permissions
|
|
7
|
-
permission:
|
|
12
|
+
permissions?: any[];
|
|
13
|
+
permission: string;
|
|
8
14
|
context?: any;
|
|
9
15
|
children: React.ReactNode;
|
|
10
|
-
}) =>
|
|
11
|
-
canDo: (granted:
|
|
16
|
+
}) => import("react/jsx-runtime").JSX.Element | null;
|
|
17
|
+
canDo: (granted: any[], permission: string, context?: any) => boolean;
|
|
12
18
|
};
|
|
@@ -1,14 +1,30 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
1
2
|
import * as React from "react";
|
|
2
|
-
export function createAccessUI() {
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
export function createAccessUI(engine) {
|
|
4
|
+
/**
|
|
5
|
+
* Hook to check permission.
|
|
6
|
+
* If engine is provided, it uses the engine (pre-bound).
|
|
7
|
+
* Otherwise, it requires granted permissions as first argument (legacy).
|
|
8
|
+
*/
|
|
9
|
+
function useCan(grantedOrPermission, permissionOrContext, maybeContext) {
|
|
10
|
+
return React.useMemo(() => {
|
|
11
|
+
if (engine) {
|
|
12
|
+
// Pre-bound: useCan(permission, context)
|
|
13
|
+
// We don't have the role here, so we assume the engine instance
|
|
14
|
+
// is already bound to a role or we need to handle it differently.
|
|
15
|
+
// Actually, createAccess will wrap this.
|
|
16
|
+
return engine.can(grantedOrPermission, permissionOrContext);
|
|
17
|
+
}
|
|
18
|
+
// Legacy: useCan(granted, permission, context)
|
|
19
|
+
return canDo(grantedOrPermission, permissionOrContext, maybeContext);
|
|
20
|
+
}, [grantedOrPermission, permissionOrContext, maybeContext]);
|
|
5
21
|
}
|
|
6
22
|
function Can({ permissions, permission, context, children, }) {
|
|
7
|
-
|
|
23
|
+
const allowed = engine
|
|
24
|
+
? engine.can(permission, context)
|
|
25
|
+
: canDo(permissions || [], permission, context); // Fallback for legacy
|
|
26
|
+
return allowed ? _jsx(_Fragment, { children: children }) : null;
|
|
8
27
|
}
|
|
9
|
-
// ✅ Function-level permission checker
|
|
10
|
-
// Note: This is a static list check. If conditions are needed on frontend,
|
|
11
|
-
// the frontend list should contain the logic or be checked against a role-aware engine.
|
|
12
28
|
function canDo(granted, permission, context) {
|
|
13
29
|
if (granted.includes("*"))
|
|
14
30
|
return true;
|
package/package.json
CHANGED
|
@@ -1,30 +1,33 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@lbstack/accessx",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A role & resource based access control system with end to end type safety.",
|
|
5
|
-
"license": "ISC",
|
|
6
|
-
"author": "",
|
|
7
|
-
"type": "module",
|
|
8
|
-
"main": "./dist/index.js",
|
|
9
|
-
"module": "./dist/index.js",
|
|
10
|
-
"types": "./dist/index.d.ts",
|
|
11
|
-
"files": [
|
|
12
|
-
"dist",
|
|
13
|
-
"README.md"
|
|
14
|
-
],
|
|
15
|
-
"scripts": {
|
|
16
|
-
"build": "tsc",
|
|
17
|
-
"test": "jest",
|
|
18
|
-
"prepublishOnly": "npm run build"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
}
|
|
30
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@lbstack/accessx",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "A role & resource based access control system with end to end type safety.",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"author": "@lbstack",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"test": "jest",
|
|
18
|
+
"prepublishOnly": "npm run build",
|
|
19
|
+
"release:patch": "npm version patch && npm publish",
|
|
20
|
+
"release:minor": "npm version minor && npm publish",
|
|
21
|
+
"release:major": "npm version major && npm publish"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/jest": "^29.5.11",
|
|
25
|
+
"@types/react": "^18.2.48",
|
|
26
|
+
"jest": "^29.7.0",
|
|
27
|
+
"ts-jest": "^29.1.1",
|
|
28
|
+
"typescript": "^5.3.3"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"react": ">=16.8.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/readme.md
CHANGED
|
@@ -133,32 +133,114 @@ const permissions = access.normalizePermissions(permissionsFromDb);
|
|
|
133
133
|
|
|
134
134
|
Ensures only valid generated permissions are used.
|
|
135
135
|
|
|
136
|
-
Login Response
|
|
137
136
|
res.json({
|
|
138
137
|
user,
|
|
139
138
|
permissions: access.resolvePermissions(user.role),
|
|
140
139
|
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## 🔑 Assigning Permissions
|
|
143
|
+
|
|
144
|
+
There are two ways to assign permissions to roles: **Manual (Static)** and **Dynamic (Database-linked)**.
|
|
145
|
+
|
|
146
|
+
### 1. Manual Assignment (Static)
|
|
147
|
+
Best for hardcoded defaults or simple applications. This is the standard, **non-mandatory** approach.
|
|
141
148
|
|
|
142
|
-
|
|
149
|
+
```typescript
|
|
150
|
+
// Single permission
|
|
151
|
+
access.allow("ADMIN", "USER:DELETE");
|
|
143
152
|
|
|
144
|
-
|
|
145
|
-
|
|
153
|
+
// Multiple permissions
|
|
154
|
+
access.allow("EDITOR", ["BLOGS:CREATE", "BLOGS:READ", "BLOGS:UPDATE"]);
|
|
146
155
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
156
|
+
// With custom ABAC conditions
|
|
157
|
+
access.allow("USER", "BLOG:UPDATE", (context) => {
|
|
158
|
+
return context.post.authorId === context.user.id;
|
|
159
|
+
});
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### 2. Dynamic Assignment (Database + Cache)
|
|
163
|
+
Best for production apps where permissions are managed in a DB or Admin Panel. This is **optional** but provides powerful caching and auto-sync benefits.
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
await access.assignPermissions("ADMIN",
|
|
167
|
+
// 1. Fetcher: Returns the list of valid permissions from your DB
|
|
168
|
+
async () => {
|
|
169
|
+
const permissions = await db.query("SELECT key FROM permissions WHERE role = 'ADMIN'");
|
|
170
|
+
return permissions.map(p => p.key);
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
// OPTIONAL: A fast key-check (e.g., Redis version or DB timestamp)
|
|
174
|
+
// The engine only re-runs the Fetcher if this key changes.
|
|
175
|
+
invalidateKey: async () => await redis.get("perms:admin:version"),
|
|
176
|
+
|
|
177
|
+
// OPTIONAL: Auto-check for updates every 60 seconds in the background
|
|
178
|
+
interval: 60000
|
|
179
|
+
}
|
|
151
180
|
);
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
> [!TIP]
|
|
184
|
+
> **Extra Benefits**: By using `invalidateKey`, you avoid hitting your database for every permission check. The engine keeps permissions in an in-memory cache and only refetches when your "version" key in Redis/DB changes.
|
|
185
|
+
|
|
186
|
+
## 🔄 Manual Refresh & Sync
|
|
187
|
+
|
|
188
|
+
If you don't use the `interval` option, or if you need to force a sync after an admin update, use the `refresh` method.
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
// Forces the engine to check invalidateKeys and re-fetch if they changed
|
|
192
|
+
await access.refresh();
|
|
193
|
+
|
|
194
|
+
// Refresh only a specific role
|
|
195
|
+
await access.refresh("ADMIN");
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## 🎨 Frontend Usage (React)
|
|
199
|
+
|
|
200
|
+
Frontend components are reactive. When you call `access.refresh()` or when an `interval` triggers an update, all components using the hooks will automatically re-render.
|
|
201
|
+
|
|
202
|
+
### 1. `useCan` Hook (Engine Bound)
|
|
203
|
+
Automatically re-renders when the engine's permissions for the given role change.
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
const canEdit = access.useCan("EDITOR", "BLOGS:UPDATE");
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### 2. `usePermissions` Hook (Flexible Source)
|
|
210
|
+
Manage permissions from any source (Static, Async, or Role). Provides `loading` state and a manual `refresh` trigger.
|
|
211
|
+
|
|
212
|
+
```tsx
|
|
213
|
+
const { permissions, loading, refresh } = access.usePermissions(async () => {
|
|
214
|
+
const res = await api.get("/my-permissions");
|
|
215
|
+
return res.data;
|
|
216
|
+
});
|
|
152
217
|
|
|
153
|
-
<
|
|
218
|
+
if (loading) return <Spinner />;
|
|
154
219
|
|
|
155
|
-
|
|
156
|
-
<
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
220
|
+
return (
|
|
221
|
+
<div>
|
|
222
|
+
<button onClick={() => refresh()}>Sync Permissions</button>
|
|
223
|
+
<Can permissions={permissions} permission="BLOG:CREATE">
|
|
224
|
+
<CreatePost />
|
|
225
|
+
</Can>
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### 3. `<Can />` Component
|
|
231
|
+
Works with both roles (engine-bound) and explicit permission arrays.
|
|
232
|
+
|
|
233
|
+
```tsx
|
|
234
|
+
// Role-based (Reactive)
|
|
235
|
+
<access.Can role="ADMIN" permission="USER:DELETE">
|
|
236
|
+
<DeleteButton />
|
|
237
|
+
</access.Can>
|
|
238
|
+
|
|
239
|
+
// Permission-based
|
|
240
|
+
<access.Can permissions={userPerms} permission="BLOGS:READ">
|
|
241
|
+
<PostList />
|
|
161
242
|
</access.Can>
|
|
243
|
+
```
|
|
162
244
|
|
|
163
245
|
🧠 Type Safety & Autocomplete
|
|
164
246
|
|
|
@@ -184,62 +266,23 @@ access.permissionKeys
|
|
|
184
266
|
|
|
185
267
|
Backend
|
|
186
268
|
access.allow(role, permission)
|
|
269
|
+
access.assignPermissions(role, perms, options) // Async: Supports fetchers & invalidation
|
|
187
270
|
access.can(role, permission)
|
|
188
271
|
access.resolvePermissions(role)
|
|
189
272
|
access.normalizePermissions(raw)
|
|
273
|
+
access.refresh(role?) // Async: Trigger key check and conditional refetch
|
|
190
274
|
|
|
191
275
|
Frontend
|
|
192
276
|
access.useCan(permissions, permission)
|
|
193
277
|
<access.Can />
|
|
194
278
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
Identity ❌ ✅
|
|
201
|
-
RBAC Core ✅ ✅
|
|
202
|
-
Type Safety ✅ ❌
|
|
203
|
-
Frontend Hooks ✅ ❌
|
|
204
|
-
Performance ✅ ❌
|
|
205
|
-
Admin UI ❌ ✅
|
|
206
|
-
Multi-App SSO ❌ ✅
|
|
207
|
-
Runtime Policy Change ❌ ✅
|
|
208
|
-
Enterprise Compliance ❌ ✅
|
|
209
|
-
Ops Overhead Low High
|
|
210
|
-
Strengths
|
|
211
|
-
|
|
212
|
-
Excellent developer experience (TypeScript-first)
|
|
213
|
-
|
|
214
|
-
Frontend hooks and React components
|
|
215
|
-
|
|
216
|
-
High performance and low latency
|
|
217
|
-
|
|
218
|
-
Embedded, framework-agnostic, zero ops
|
|
219
|
-
|
|
220
|
-
Weaknesses Compared to Keycloak
|
|
221
|
-
|
|
222
|
-
No identity or login management
|
|
223
|
-
|
|
224
|
-
No multi-application SSO
|
|
225
|
-
|
|
226
|
-
No admin UI or delegation
|
|
227
|
-
|
|
228
|
-
Permissions mostly static (redeploy needed for new resources/actions)
|
|
229
|
-
|
|
230
|
-
No ABAC / advanced policy language
|
|
231
|
-
|
|
232
|
-
No enterprise audit / compliance tooling
|
|
233
|
-
|
|
234
|
-
Ideal Usage
|
|
235
|
-
Keycloak (Authentication + Identity)
|
|
236
|
-
↓
|
|
237
|
-
JWT / Claims
|
|
238
|
-
↓
|
|
239
|
-
@accessx/core (Authorization + UI permissions)
|
|
279
|
+
🔐 Multi-Permission Assignment
|
|
280
|
+
Assign multiple permissions to a role at once:
|
|
281
|
+
```typescript
|
|
282
|
+
access.allow("EDITOR", ["BLOGS:CREATE", "BLOGS:READ", "BLOGS:UPDATE"]);
|
|
283
|
+
```
|
|
240
284
|
|
|
241
285
|
|
|
242
|
-
This is complementary, not a replacement for Keycloak.
|
|
243
286
|
|
|
244
287
|
🏆 Why Use @accessx/core?
|
|
245
288
|
|
|
@@ -288,17 +331,10 @@ npm test
|
|
|
288
331
|
|
|
289
332
|
## 🛣️ Roadmap
|
|
290
333
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
Attribute-based access control (ABAC)
|
|
296
|
-
|
|
297
|
-
Permission groups
|
|
298
|
-
|
|
299
|
-
JWT permission compression
|
|
300
|
-
|
|
301
|
-
CLI generator
|
|
334
|
+
- [ ] Multi-role users
|
|
335
|
+
- [ ] Permission groups
|
|
336
|
+
- [ ] JWT permission compression
|
|
337
|
+
- [ ] CLI generator
|
|
302
338
|
|
|
303
339
|
📄 License
|
|
304
340
|
|