Skip to content

Inheritance and default parents

A subject inherits the grants of every id listed in its parents, and those parents inherit from their own parents, and so on. Inheritance is resolved at evaluation time — nothing is copied or persisted.

await vperms.saveSubject("workspace", {
id: "user",
type: SubjectType.User,
parents: ["developers"],
});
await vperms.saveSubject("workspace", {
id: "developers",
type: SubjectType.Group,
parents: ["staff"],
});
await vperms.setPermission("workspace", "staff", "posts.read", true);
await vperms.can("workspace", "user", "posts.read"); // true (two levels up)

Evaluation is two-phase:

  1. Direct grants of the subject are matched first. If any direct grant matches, it decides the result and inheritance is not consulted.
  2. Otherwise the inherited grants are resolved and matched.

If neither phase matches, the built-in vperms.subject.me.permissions grant is checked last.

await vperms.setPermission("workspace", "developers", "posts.read", true);
await vperms.setPermission("workspace", "user", "posts.read", false);
await vperms.can("workspace", "user", "posts.read"); // false (direct deny wins)

Parents come from three sources, called layers. Lower is higher priority:

Layer Name Source
0 Explicit subject.parents
1 Type default defaultParents.byType[type]
2 Global default defaultParents.global
3 Built-in the self-permissions grant

The next layer is only consulted when the previous one produced no matching permission — not merely when it had no grants. Within a layer, closer parents take priority over more distant ancestors.

defaultParents applies virtual parents to every subject the service evaluates, without storing them:

const vperms = new VeguiPermsService({
adapter,
defaultParents: {
global: ["everyone"],
byType: { user: ["users"], service: ["services"] },
},
});

Parents reached through a default are tagged with the worse of the two layers, so a default edge can never outrank an explicit edge. A parent reached through a default also contributes its own explicit parents and its own defaults.

Default parents are validated by DefaultParentsSchema. They cannot be negation directives — opting out happens on the subject, not in configuration.

Prefix an entry in parents with ! to opt the subject out of that virtual parent:

await vperms.saveSubject("workspace", {
id: "user",
type: SubjectType.User,
parents: ["!everyone"],
});

Negation behaves as follows:

  • It only opts out of virtual parents (byType and global), removing the id from both sources at once.
  • It never removes an explicit parent with the same id. If the subject also lists everyone explicitly, the explicit entry wins and inheritance still happens.
  • When declared on the evaluated subject, it is propagated through the whole walk: the negated id is never reached through a virtual source, not even when an intermediate parent would apply it as one of its own defaults.
  • A parent that lists the negated id explicitly still reintroduces it.

The negation prefix is exported as VIRTUAL_PARENT_NEGATION ("!"), and splitParents(parents) returns the explicit parents and the set of excluded ids:

import { splitParents } from "vperms";
splitParents(["developers", "!everyone"]);
// { explicit: ["developers"], excluded: Set { "everyone" } }

Inheritance uses a visited set, so cyclic parents (a → b → a) never cause infinite recursion and duplicate paths are collapsed. The result does not depend on the order of the parents array: every tiebreak is content-based.

import { effectiveParentLayers } from "vperms";
effectiveParentLayers(
{ id: "user", type: SubjectType.User, parents: ["developers", "!everyone"] },
{ global: ["everyone"], byType: { user: ["users"] } },
);
// {
// explicit: ["developers"],
// byType: ["users"],
// global: [],
// }

effectiveParentLayers(subject, defaults) returns the three layers already deduplicated, with negation applied. Continue with Resolution to see how the engine turns all of this into a single effective permission set.