@aooth/arbac-core 0.1.6 → 0.1.8

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/index.cjs CHANGED
@@ -81,37 +81,61 @@ var Arbac = class {
81
81
  return this;
82
82
  }
83
83
  /**
84
- * Evaluates whether a given action on a resource is allowed for a user with specified roles.
84
+ * Evaluates whether a given action on a resource is allowed for a user with
85
+ * the specified roles, returning the allow decision plus any applicable scopes.
86
+ *
87
+ * When `user.attenuate` is supplied (the credential-claims bridge) the policy
88
+ * is evaluated TWICE and the OUTCOMES are intersected — see the `attenuate`
89
+ * field for the restrict-only safety guarantee.
85
90
  *
86
- * @param {{
87
- * roleIds: string[];
88
- * userId: string;
89
- * resource: string;
90
- * action: string;
91
- * }} res The options for the evaluation.
92
91
  * @returns {Promise<TArbacEvalResult<TScope>>} The result of the evaluation, including whether the action is allowed and any applicable scopes.
93
92
  */
94
93
  async evaluate(res, user) {
95
94
  this.registerResource(res.resource);
96
- const roles = user.roles.map((r) => {
95
+ let resolvedAttrs;
96
+ const userAttrs = async () => {
97
+ if (resolvedAttrs === void 0) resolvedAttrs = typeof user.attrs === "function" ? await user.attrs(user.id) : user.attrs;
98
+ return resolvedAttrs;
99
+ };
100
+ const userEval = await this.evaluateForRoles(res, user.roles, userAttrs, user.id);
101
+ if (!user.attenuate) return userEval;
102
+ const claimRoles = user.attenuate.roles;
103
+ const claimAttrs = user.attenuate.attrs;
104
+ const credRoles = claimRoles ? user.roles.filter((r) => claimRoles.includes(r)) : user.roles;
105
+ const credAttrs = claimAttrs ? async () => ({
106
+ ...await userAttrs(),
107
+ ...claimAttrs
108
+ }) : userAttrs;
109
+ const credEval = await this.evaluateForRoles(res, credRoles, credAttrs, user.id);
110
+ if (!userEval.allowed || !credEval.allowed) return { allowed: false };
111
+ return {
112
+ allowed: true,
113
+ scopes: userEval.scopes,
114
+ credScopes: credEval.scopes
115
+ };
116
+ }
117
+ /**
118
+ * One evaluation pass over a concrete role-id set + attrs resolver. Carries
119
+ * the deny-wins / allow-union / `{}` universe-sentinel semantics; `evaluate`
120
+ * runs it once (no attenuation) or twice (ceiling + narrowed credential view).
121
+ */
122
+ async evaluateForRoles(res, roleIds, getAttrs, id) {
123
+ const roles = roleIds.map((r) => {
97
124
  const role = this.resources[res.resource][r];
98
125
  if (!role && !this.roles[r] && !this.warnedRoles.has(r)) {
99
126
  this.warnedRoles.add(r);
100
- console.warn(`Role "${r}" assigned to user "${user.id}" does not exist.`);
127
+ console.warn(`Role "${r}" assigned to user "${id}" does not exist.`);
101
128
  }
102
129
  return role;
103
130
  }).filter(Boolean);
104
131
  if (roles.length === 0) return { allowed: false };
105
132
  for (const role of roles) for (const rule of role.deny) if (rule._actionRegex.test(res.action)) return { allowed: false };
106
- let userAttrs;
107
133
  const scopes = [];
108
134
  let allowed = false;
109
135
  for (const role of roles) for (const rule of role.allow) if (rule._actionRegex.test(res.action)) {
110
136
  allowed = true;
111
- if (rule.scope) {
112
- if (!userAttrs) userAttrs = typeof user.attrs === "function" ? await user.attrs(user.id) : user.attrs;
113
- scopes.push(rule.scope(userAttrs, String(user.id)));
114
- } else scopes.push({});
137
+ if (rule.scope) scopes.push(rule.scope(await getAttrs(), String(id)));
138
+ else scopes.push({});
115
139
  }
116
140
  return allowed ? {
117
141
  allowed,
package/dist/index.d.cts CHANGED
@@ -2,6 +2,16 @@
2
2
  interface TArbacEvalResult<TScope> {
3
3
  allowed: boolean;
4
4
  scopes?: TScope[];
5
+ /**
6
+ * Present ONLY on an attenuated evaluation (`user.attenuate` was supplied).
7
+ * The scopes from the credential's narrowed pass, to be CONJOINED with
8
+ * `scopes` (the full-authority pass) by the scope layer — a row/field is
9
+ * effective only if BOTH passes admit it. The conjunction is the scope
10
+ * shape's responsibility (the engine is scope-agnostic), so the two unions
11
+ * are surfaced separately here rather than pre-combined. Absent on a normal
12
+ * (non-attenuated) evaluation.
13
+ */
14
+ credScopes?: TScope[];
5
15
  }
6
16
  interface TArbacRole<TUserAttrs, TScope> {
7
17
  id: string;
@@ -62,14 +72,13 @@ declare class Arbac<TUserAttrs extends object, TScope extends object> {
62
72
  */
63
73
  protected evalRoleForResource(roleId: string, resourceId: string): Arbac<TUserAttrs, TScope>;
64
74
  /**
65
- * Evaluates whether a given action on a resource is allowed for a user with specified roles.
75
+ * Evaluates whether a given action on a resource is allowed for a user with
76
+ * the specified roles, returning the allow decision plus any applicable scopes.
77
+ *
78
+ * When `user.attenuate` is supplied (the credential-claims bridge) the policy
79
+ * is evaluated TWICE and the OUTCOMES are intersected — see the `attenuate`
80
+ * field for the restrict-only safety guarantee.
66
81
  *
67
- * @param {{
68
- * roleIds: string[];
69
- * userId: string;
70
- * resource: string;
71
- * action: string;
72
- * }} res The options for the evaluation.
73
82
  * @returns {Promise<TArbacEvalResult<TScope>>} The result of the evaluation, including whether the action is allowed and any applicable scopes.
74
83
  */
75
84
  evaluate<T extends string | undefined>(res: {
@@ -79,7 +88,36 @@ declare class Arbac<TUserAttrs extends object, TScope extends object> {
79
88
  id: T;
80
89
  roles: string[];
81
90
  attrs: TUserAttrs | ((userId: T) => TUserAttrs | Promise<TUserAttrs>);
91
+ /**
92
+ * Opt-in, restrict-only attenuation sourced from the authenticated
93
+ * credential's claims. When present the policy runs twice — at full
94
+ * authority (the ceiling) and at the credential's narrowed view — and
95
+ * the outcomes are intersected: `allowed` is ANDed (so dropping a role
96
+ * can never drop a matching `deny` and thus can never escalate a denied
97
+ * action), and the two passes' scope lists are surfaced separately as
98
+ * `scopes` (ceiling) and `credScopes` (narrowed) for the scope layer to
99
+ * conjoin. A credential can therefore only ever do/see/affect LESS than
100
+ * its owning user — escalation-proof even against an untrusted minter.
101
+ *
102
+ * `roles: []` → no roles (deny-all, fail-closed). An OMITTED `roles` key
103
+ * → keep all the user's roles (attrs-only narrowing). A claimed role the
104
+ * user lacks is dropped by the intersection (fail-closed). Absent
105
+ * `attenuate` → a single evaluation, byte-for-byte today's behavior.
106
+ */
107
+ attenuate?: {
108
+ roles?: string[];
109
+ attrs?: Partial<TUserAttrs>;
110
+ };
82
111
  }): Promise<TArbacEvalResult<TScope>>;
112
+ /**
113
+ * One evaluation pass over a concrete role-id set + attrs resolver. Carries
114
+ * the deny-wins / allow-union / `{}` universe-sentinel semantics; `evaluate`
115
+ * runs it once (no attenuation) or twice (ceiling + narrowed credential view).
116
+ */
117
+ protected evaluateForRoles(res: {
118
+ resource: string;
119
+ action: string;
120
+ }, roleIds: string[], getAttrs: () => Promise<TUserAttrs>, id: string | undefined): Promise<TArbacEvalResult<TScope>>;
83
121
  }
84
122
  //#endregion
85
123
  //#region src/utils.d.ts
package/dist/index.d.mts CHANGED
@@ -2,6 +2,16 @@
2
2
  interface TArbacEvalResult<TScope> {
3
3
  allowed: boolean;
4
4
  scopes?: TScope[];
5
+ /**
6
+ * Present ONLY on an attenuated evaluation (`user.attenuate` was supplied).
7
+ * The scopes from the credential's narrowed pass, to be CONJOINED with
8
+ * `scopes` (the full-authority pass) by the scope layer — a row/field is
9
+ * effective only if BOTH passes admit it. The conjunction is the scope
10
+ * shape's responsibility (the engine is scope-agnostic), so the two unions
11
+ * are surfaced separately here rather than pre-combined. Absent on a normal
12
+ * (non-attenuated) evaluation.
13
+ */
14
+ credScopes?: TScope[];
5
15
  }
6
16
  interface TArbacRole<TUserAttrs, TScope> {
7
17
  id: string;
@@ -62,14 +72,13 @@ declare class Arbac<TUserAttrs extends object, TScope extends object> {
62
72
  */
63
73
  protected evalRoleForResource(roleId: string, resourceId: string): Arbac<TUserAttrs, TScope>;
64
74
  /**
65
- * Evaluates whether a given action on a resource is allowed for a user with specified roles.
75
+ * Evaluates whether a given action on a resource is allowed for a user with
76
+ * the specified roles, returning the allow decision plus any applicable scopes.
77
+ *
78
+ * When `user.attenuate` is supplied (the credential-claims bridge) the policy
79
+ * is evaluated TWICE and the OUTCOMES are intersected — see the `attenuate`
80
+ * field for the restrict-only safety guarantee.
66
81
  *
67
- * @param {{
68
- * roleIds: string[];
69
- * userId: string;
70
- * resource: string;
71
- * action: string;
72
- * }} res The options for the evaluation.
73
82
  * @returns {Promise<TArbacEvalResult<TScope>>} The result of the evaluation, including whether the action is allowed and any applicable scopes.
74
83
  */
75
84
  evaluate<T extends string | undefined>(res: {
@@ -79,7 +88,36 @@ declare class Arbac<TUserAttrs extends object, TScope extends object> {
79
88
  id: T;
80
89
  roles: string[];
81
90
  attrs: TUserAttrs | ((userId: T) => TUserAttrs | Promise<TUserAttrs>);
91
+ /**
92
+ * Opt-in, restrict-only attenuation sourced from the authenticated
93
+ * credential's claims. When present the policy runs twice — at full
94
+ * authority (the ceiling) and at the credential's narrowed view — and
95
+ * the outcomes are intersected: `allowed` is ANDed (so dropping a role
96
+ * can never drop a matching `deny` and thus can never escalate a denied
97
+ * action), and the two passes' scope lists are surfaced separately as
98
+ * `scopes` (ceiling) and `credScopes` (narrowed) for the scope layer to
99
+ * conjoin. A credential can therefore only ever do/see/affect LESS than
100
+ * its owning user — escalation-proof even against an untrusted minter.
101
+ *
102
+ * `roles: []` → no roles (deny-all, fail-closed). An OMITTED `roles` key
103
+ * → keep all the user's roles (attrs-only narrowing). A claimed role the
104
+ * user lacks is dropped by the intersection (fail-closed). Absent
105
+ * `attenuate` → a single evaluation, byte-for-byte today's behavior.
106
+ */
107
+ attenuate?: {
108
+ roles?: string[];
109
+ attrs?: Partial<TUserAttrs>;
110
+ };
82
111
  }): Promise<TArbacEvalResult<TScope>>;
112
+ /**
113
+ * One evaluation pass over a concrete role-id set + attrs resolver. Carries
114
+ * the deny-wins / allow-union / `{}` universe-sentinel semantics; `evaluate`
115
+ * runs it once (no attenuation) or twice (ceiling + narrowed credential view).
116
+ */
117
+ protected evaluateForRoles(res: {
118
+ resource: string;
119
+ action: string;
120
+ }, roleIds: string[], getAttrs: () => Promise<TUserAttrs>, id: string | undefined): Promise<TArbacEvalResult<TScope>>;
83
121
  }
84
122
  //#endregion
85
123
  //#region src/utils.d.ts
package/dist/index.mjs CHANGED
@@ -80,37 +80,61 @@ var Arbac = class {
80
80
  return this;
81
81
  }
82
82
  /**
83
- * Evaluates whether a given action on a resource is allowed for a user with specified roles.
83
+ * Evaluates whether a given action on a resource is allowed for a user with
84
+ * the specified roles, returning the allow decision plus any applicable scopes.
85
+ *
86
+ * When `user.attenuate` is supplied (the credential-claims bridge) the policy
87
+ * is evaluated TWICE and the OUTCOMES are intersected — see the `attenuate`
88
+ * field for the restrict-only safety guarantee.
84
89
  *
85
- * @param {{
86
- * roleIds: string[];
87
- * userId: string;
88
- * resource: string;
89
- * action: string;
90
- * }} res The options for the evaluation.
91
90
  * @returns {Promise<TArbacEvalResult<TScope>>} The result of the evaluation, including whether the action is allowed and any applicable scopes.
92
91
  */
93
92
  async evaluate(res, user) {
94
93
  this.registerResource(res.resource);
95
- const roles = user.roles.map((r) => {
94
+ let resolvedAttrs;
95
+ const userAttrs = async () => {
96
+ if (resolvedAttrs === void 0) resolvedAttrs = typeof user.attrs === "function" ? await user.attrs(user.id) : user.attrs;
97
+ return resolvedAttrs;
98
+ };
99
+ const userEval = await this.evaluateForRoles(res, user.roles, userAttrs, user.id);
100
+ if (!user.attenuate) return userEval;
101
+ const claimRoles = user.attenuate.roles;
102
+ const claimAttrs = user.attenuate.attrs;
103
+ const credRoles = claimRoles ? user.roles.filter((r) => claimRoles.includes(r)) : user.roles;
104
+ const credAttrs = claimAttrs ? async () => ({
105
+ ...await userAttrs(),
106
+ ...claimAttrs
107
+ }) : userAttrs;
108
+ const credEval = await this.evaluateForRoles(res, credRoles, credAttrs, user.id);
109
+ if (!userEval.allowed || !credEval.allowed) return { allowed: false };
110
+ return {
111
+ allowed: true,
112
+ scopes: userEval.scopes,
113
+ credScopes: credEval.scopes
114
+ };
115
+ }
116
+ /**
117
+ * One evaluation pass over a concrete role-id set + attrs resolver. Carries
118
+ * the deny-wins / allow-union / `{}` universe-sentinel semantics; `evaluate`
119
+ * runs it once (no attenuation) or twice (ceiling + narrowed credential view).
120
+ */
121
+ async evaluateForRoles(res, roleIds, getAttrs, id) {
122
+ const roles = roleIds.map((r) => {
96
123
  const role = this.resources[res.resource][r];
97
124
  if (!role && !this.roles[r] && !this.warnedRoles.has(r)) {
98
125
  this.warnedRoles.add(r);
99
- console.warn(`Role "${r}" assigned to user "${user.id}" does not exist.`);
126
+ console.warn(`Role "${r}" assigned to user "${id}" does not exist.`);
100
127
  }
101
128
  return role;
102
129
  }).filter(Boolean);
103
130
  if (roles.length === 0) return { allowed: false };
104
131
  for (const role of roles) for (const rule of role.deny) if (rule._actionRegex.test(res.action)) return { allowed: false };
105
- let userAttrs;
106
132
  const scopes = [];
107
133
  let allowed = false;
108
134
  for (const role of roles) for (const rule of role.allow) if (rule._actionRegex.test(res.action)) {
109
135
  allowed = true;
110
- if (rule.scope) {
111
- if (!userAttrs) userAttrs = typeof user.attrs === "function" ? await user.attrs(user.id) : user.attrs;
112
- scopes.push(rule.scope(userAttrs, String(user.id)));
113
- } else scopes.push({});
136
+ if (rule.scope) scopes.push(rule.scope(await getAttrs(), String(id)));
137
+ else scopes.push({});
114
138
  }
115
139
  return allowed ? {
116
140
  allowed,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/arbac-core",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Advanced Role-Based Access Control (ARBAC) engine",
5
5
  "keywords": [
6
6
  "access-control",