Object access
Two layers, not one
Method access and object access answer different questions and neither replaces
the other.
Method access — may this actor call at all? Heldrp/community/listTopics
in the permission tree, issued by , enforced by the nrpc guard beforerp-access
the handler runs. Without it, anyone can call .deleteTopic
Object access — which topics does return? That is thislistTopics
document. Without it, an actor allowed to call receives every topiclistTopics
in the store.
Object access reuses the method-access transport rather than adding a second
system: a tag is an ordinary grant under the kind, carried by the same JWTtg
and issued by the same calls.
The mechanism
Two things, both local to the service that owns the data.
One table per store, serving every object type in it:
CREATE TABLE access_tags (
objectId TEXT NOT NULL,
tag TEXT NOT NULL,
PRIMARY KEY (tag, objectId)
);
CREATE INDEX access_tags_object ON access_tags (objectId);
And the tags an actor is matched by. There is no object-type column: the join
back to the owning table does that filtering, since an id belonging to another
type is not found there.
Tags
Group tags — , team-support. Stored per user in the moderatoraccess
KV store of , inside the existing permission tree asrp-access, and carried in the JWT. Granted with tg/<tag>/*(mode).addTagToUser
The personal tag — . Never stored and never issued: it isu-<userId>
derived from the token subject, which every verified token already carries. An
object opened to one person is tagged with that person's tag, so an individual
grant costs one row and nothing in the token.
Well-known tags — (anyone, including anonymous callers) andpublic (any verified actor). Visibility is which of these an object isauthenticated
written with, not a column, which keeps every selection a lookup by tag.
So ownership, individual sharing, group access and public visibility are one
mechanism with four kinds of tag, not four mechanisms.
Selecting
A selection must start from the tag table and join to the objects. visibleFrom
in does this:back-core/access
import { listVisible, visibleFrom } from "back-core";
const page = await listVisible<Topic>(this.store.db, "topics", { limit: 50 });
const custom = await visibleFrom(this.store.db, "topics")
.selectAll("obj")
.where("obj.status", "=", "open")
.orderBy("obj.id")
.limit(50)
.execute();
The direction is not a style choice. Written flat — — SQLiteaccess_tags JOIN topics ... WHERE tag IN (...) ORDER BY topics.id
prefers to scan the object table in primary-key order to satisfy the sort. On a
table of a million rows with ten visible, that measured 363 ms for a page of
fifty. Through the subquery builds, the same page measuredvisibleFrom
0.28 ms. Both return identical results, which is why the shape has a test
asserting the query plan rather than the output.
Never filter after the query, outside the database, and never put tags in a list
column checked per row: both read the whole table.
returns listVisible taken with the same narrowing as the page.totalCount
Counting without it publishes how many objects are being hidden.
Granting
const access = new AccessTags(this.store);
await access.tagNew(id, { owner: actorId, visibility: "private" });
await access.grantToUser(id, otherUserId); // effective immediately
await access.revokeFromUser(id, otherUserId);
await access.dropObject(id); // on delete; a leftover row would
// later match a reused id
await access.requireRead(id); // throws AccessDeniedError
Group membership goes through instead, because it lives in therp-access
token:
await access.addTagToUser(userId, "team-support");
await access.removeTagFromUser(userId, "team-support");
await access.getTagsOfUser(userId);
Requirements
Ids must be unique across the tables covered by tags. A shared sequence, a
UUID, or a type prefix — whichever the store already uses. Entities not covered
by tags are unaffected; this is not a platform-wide move to UUID.
Ids must be generated by the server. With no object-type column, an id the
caller can choose is an object the caller can reach. Client-supplied ids exist
today in , rp-sales and rp-classifier and must be closed beforerp-chats
those repositories adopt tags.
Monotonic ids are a bonus, not a requirement. A shared sequence or UUIDv7
gives creation order straight from the tag index. UUIDv4 does not, and that is
the only consequence — pass an explicit instead.orderBy
Byte order must match meaning. A numeric sequence kept in a text column
needs zero padding, or sorts before "10"."9"
No or : in ids or tags. They are ; andKEY_SEPARATOR in RANGE_END_SUFFIX; a separator inside a key makes it ambiguousback-core
in KV stores. rejects such tags.addTagToUser
must be NRPC_ACCESS_MODE. With the guard off, the context userrequired
falls back to the envelope, which the caller writes — the personal tag would
then be derived from a client-supplied id. It is in required andconfs/dev; the code default is confs/prod for local runs and tests.off
Non-SQL stores
KV: the same shape as keys in the same store — forward,tag:<tag>:<objectId> reverse. Reading a list is a prefix scan per tag, mergedobj:<objectId>:<tag>
and de-duplicated. Honest pagination needs a cursor in , which currentlykvList
returns a whole range; until then KV lists are bounded by what fits in memory.
Files and JSON ( extends JsonStore): no tag index of their own. TheFileStore
file name is the object id, and access to it is the access of the record that
references it, which lives in a SQL or KV store of the same service.
Graph: tag as a node, grant as an edge, traversal starting from the tag.
Accepted limits
Removing a group tag waits for the token to be reissued — DEFAULT_TTL_SECONDS
is 90 days. Individual grants and revocations are immediate, because the table
is read on every request. Access that must change at once uses the personal tag,
not a group.
Exact needs the join; with several overlapping tags the count istotalCount
over distinct ids, which costs more as the visible set grows.
Sorting by any field other than the id is cheap while few objects match a
tag. If one tag ever covers hundreds of thousands of objects, that order has to
be denormalised into the tag table or dropped.