Change language

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 rp/community/listTopics at all? Held
in the permission tree, issued by rp-access, enforced by the nrpc guard before
the handler runs. Without it, anyone can call deleteTopic.

Object access — which topics does listTopics return? That is this
document. Without it, an actor allowed to call listTopics receives every topic
in the store.

Object access reuses the method-access transport rather than adding a second
system: a tag is an ordinary grant under the tg kind, carried by the same JWT
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, moderator. Stored per user in the access
KV store of rp-access, inside the existing permission tree as
tg/<tag>/*(mode), and carried in the JWT. Granted with addTagToUser.

The personal tag — u-<userId>. Never stored and never issued: it is
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 — public (anyone, including anonymous callers) and
authenticated (any verified actor). Visibility is which of these an object is
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 back-core/access does this:

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 —
access_tags JOIN topics ... WHERE tag IN (...) ORDER BY topics.id — SQLite
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 visibleFrom builds, the same page measured
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.

listVisible returns totalCount taken with the same narrowing as the page.
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 rp-access instead, because it lives in the
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, rp-classifier and rp-chats and must be closed before
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 orderBy instead.

Byte order must match meaning. A numeric sequence kept in a text column
needs zero padding, or "10" sorts before "9".

No : or ; in ids or tags. They are KEY_SEPARATOR and
RANGE_END_SUFFIX in back-core; a separator inside a key makes it ambiguous
in KV stores. addTagToUser rejects such tags.

NRPC_ACCESS_MODE must be required. With the guard off, the context user
falls back to the envelope, which the caller writes — the personal tag would
then be derived from a client-supplied id. It is required in confs/dev and
confs/prod; the code default is off for local runs and tests.

Non-SQL stores

KV: the same shape as keys in the same store — tag:<tag>:<objectId> forward,
obj:<objectId>:<tag> reverse. Reading a list is a prefix scan per tag, merged
and de-duplicated. Honest pagination needs a cursor in kvList, which currently
returns a whole range; until then KV lists are bounded by what fits in memory.

Files and JSON (JsonStore extends FileStore): no tag index of their own. The
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 totalCount needs the join; with several overlapping tags the count is
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.