@dszp/netsapiens-lib 0.1.7 → 0.1.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.
package/dist/policy.d.ts CHANGED
@@ -13,9 +13,15 @@
13
13
  * - all users in some domains: { domains: ['acme','acme42'] }
14
14
  * - …optionally with scopes: { domains:['acme'], scopes:['Office Manager','Basic User'] }
15
15
  * - specific users: { users: ['100@acme','101@acme'] }
16
+ * - a scope MINUS a few accounts: { scopes:['Reseller'], notUsers:['105@acme'] }
16
17
  * - only when a given operator is masked in: { operators: ['admin@0000.12345.service'] }
17
18
  * - only while (not) masking: { masking: true } / { masking: false }
18
19
  *
20
+ * `notUsers` is the one NEGATIVE condition, and it exists because the positive form cannot express
21
+ * "everyone at this scope except these accounts" without enumerating the complement — a list that is
22
+ * wrong the moment an account is added, and wrong silently. It ANDs with the rest of the rule like
23
+ * every other condition, so it narrows the rule it sits on and nothing else.
24
+ *
19
25
  * Matching considers the EFFECTIVE principal (scope/domain/id = the masked user when masking);
20
26
  * `operators` matches the mask_chain operator, so you can gate on the real reseller behind a mask.
21
27
  *
@@ -29,6 +35,22 @@ export interface PolicyRule {
29
35
  domains?: string[];
30
36
  /** Effective identity (`user@domain`) must be one of these. */
31
37
  users?: string[];
38
+ /**
39
+ * Accounts this rule EXCLUDES — a denial that ANDs with the rest of the rule, narrowing it.
40
+ *
41
+ * ⚠️ It is NOT a condition on its own. A rule carrying only `notUsers` never matches, deliberately:
42
+ * "everybody except X" as a standalone rule would be an allow-all wearing an exception, and this
43
+ * engine's whole shape is that a rule must say who it admits before it says who it doesn't. Pair it
44
+ * with `scopes`/`domains`/`users` — see `hasCondition` in {@link ruleMatches}.
45
+ *
46
+ * ⚠️ **It matches the effective identity OR the operator behind a mask** — the one place this engine
47
+ * is deliberately asymmetric. Every positive condition sees the EFFECTIVE principal, so a grant
48
+ * follows the role currently being performed; masquerading is full impersonation and is meant to be.
49
+ * A denial is not about a role. It names a person, and a denial that evaporates the moment that
50
+ * person masquerades into someone else is not a denial — it is a suggestion. So this one condition
51
+ * asks both "who is acting" and "who is behind this", and refuses if either is named.
52
+ */
53
+ notUsers?: string[];
32
54
  /** Requires masking, AND the operator's `user@domain` (mask_chain) is one of these. */
33
55
  operators?: string[];
34
56
  /** Require the masking state to equal this (true = masked, false = not masked). */
package/dist/policy.js CHANGED
@@ -20,6 +20,8 @@ const scopeInList = (value, list) => {
20
20
  export function ruleMatches(p, rule) {
21
21
  // A rule with NO matchable condition (e.g. `{}` or only `description`) is NOT allow-all — that would
22
22
  // silently grant everyone. Require at least one real condition; a conditionless rule never matches.
23
+ // `notUsers` is deliberately absent from this list: it subtracts, so counting it would let
24
+ // `{notUsers:[…]}` mean "everyone else", which is the allow-all this guard exists to prevent.
23
25
  const hasCondition = rule.scopes !== undefined ||
24
26
  rule.domains !== undefined ||
25
27
  rule.users !== undefined ||
@@ -33,6 +35,10 @@ export function ruleMatches(p, rule) {
33
35
  return false;
34
36
  if (rule.users && !inList(p.id, rule.users))
35
37
  return false;
38
+ // Both identities, unlike every positive condition above — see the field's own note. A grant follows
39
+ // the role being performed; a denial follows the person performing it, through a mask.
40
+ if (rule.notUsers && (inList(p.id, rule.notUsers) || (p.operator && inList(p.operator.id, rule.notUsers))))
41
+ return false;
36
42
  if (rule.operators) {
37
43
  if (!p.operator || !inList(p.operator.id, rule.operators))
38
44
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dszp/netsapiens-lib",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Portable, Node-free NetSapiens toolkit: split read/write API clients, JWT (ns_t) validation, and a snapshot -> FlowGraph -> Mermaid call-flow resolver/renderer. Runs unchanged in a Cloudflare Worker, Node, or the browser.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,7 +55,7 @@
55
55
  "build:watch": "tsc -p tsconfig.json --watch",
56
56
  "//prepublishOnly": "Publish-only build with sourcemaps OFF. The `files` globs exclude dist/**/*.map on purpose (they point at src/, which does not ship), but tsc still emits a //# sourceMappingURL pointer into every .js/.d.ts -- so consumers' devtools 404 chasing maps that were never published. Dropping the pointer at publish time is what the exclusion always meant. A normal `pnpm build` keeps maps for link: consumers.",
57
57
  "prepublishOnly": "tsc -p tsconfig.json --sourceMap false --declarationMap false",
58
- "//test": "The offline suite \u2014 green on a fresh clone with no credentials and no fixtures. test:ns is NOT included: it needs a domain snapshot that (correctly) isn't in the repo.",
58
+ "//test": "The offline suite green on a fresh clone with no credentials and no fixtures. test:ns is NOT included: it needs a domain snapshot that (correctly) isn't in the repo.",
59
59
  "test": "pnpm run test:jwt && pnpm run test:principal && pnpm run test:resolver && pnpm run test:raster && pnpm run test:nswrite && pnpm run test:nssync && pnpm run test:eligibility && pnpm run test:nsauth && pnpm test:nssubs && pnpm test:nsdevice",
60
60
  "test:jwt": "tsx src/jwt.selftest.ts",
61
61
  "test:ns": "tsx src/nsClient.selftest.ts",