Help us grow — star us on GitHubGitHub stars
LinkedRecords

Authorization Model

Understanding LinkedRecords' built-in permission system

Overview

LinkedRecords has a unique authorization model where the user who creates data decides who can access it. Instead of centralized access control rules defined in the backend, permissions are expressed as facts in the triplestore.

Core Principle

By default, data you create is private to you. To share data with others, you create facts that grant specific permissions.

// Only you can see this document
const doc = await lr.Attribute.createKeyValue({ title: 'My Notes' });
 
// Now alice can read it
await lr.Fact.createAll([
  [aliceId, '$canRead', doc.id],
]);

Reserved Predicates

All authorization predicates start with $. These are the available predicates:

$isAccountableFor

Indicates ownership and accountability for an attribute. The accountable party is responsible for the storage quota used by the attribute.

[userId, '$isAccountableFor', attributeId]

When you create an attribute, a $isAccountableFor fact linking you to it is automatically created. You don't need to add this manually.

Transferring accountability: You can transfer accountability to a group by creating a new $isAccountableFor fact with the group as the subject. Once transferred, you are no longer accountable—the group is. This matters because accountability determines who pays for storage quota. For example, if you're a member of an organization and create documents for it, you likely want the organization to be accountable for the storage costs, not you personally.

// Transfer accountability to your organization
await lr.Fact.createAll([
  [orgId, '$isAccountableFor', doc.id],
]);
// The org now "owns" this document for quota purposes
// In order for this to work you need to be a member
// of the org and accountable for the doc. After this
// command is executed you will no longer be accountable
// for this doc and you will not be able to perform this
// operation a second time.

You can only transfer accountability to a group you're a member of - not to other users or arbitrary entities. See Accountability Transfer Rules for details.

What is a group? There is no special "group" concept in LinkedRecords. A group is simply an attribute (typically a KeyValue attribute) that you use as a node for memberships. You create an attribute, and then use its ID as the target of $isMemberOf facts. You don't need to tag it with isA or assign any terms - any attribute can serve as a group.

// Create a simple attribute - this becomes your "group"
const team = await lr.Attribute.createKeyValue({ name: 'Engineering' });
 
// Use its ID to assign memberships
await lr.Fact.createAll([
  [userId, '$isMemberOf', team.id],
]);
 
// Grant the group access to another attribute
await lr.Fact.createAll([
  [team.id, '$canAccess', documentId],
]);
// Now all members of the team can read and write the document

$isMemberOf

Grants membership in a group, inheriting the group's access permissions.

[userId, '$isMemberOf', teamId]

Members can:

  • Read and write attributes the group has $canAccess to
  • Read attributes the group has $canRead to
  • Use subjects the group has $canRefine permission for
  • Use objects the group has $canReferTo permission for

$isHostOf

Grants host privileges for a group. Hosts can add new members.

[userId, '$isHostOf', teamId]

Hosts have all member permissions, plus:

  • Can add new members to the group ($isMemberOf)
  • Can make other users hosts ($isHostOf)
  • Can remove members

To create a $isHostOf fact, you must either be accountable for the group or be a host of it.

$canRead

Grants read-only access to an attribute.

[userId, '$canRead', attributeId]
// or for a group:
[teamId, '$canRead', attributeId]

$canAccess

Grants read and write access to an attribute.

[userId, '$canAccess', attributeId]
// or for a group:
[teamId, '$canAccess', attributeId]

$canRefine

Grants permission to use an attribute as the subject in facts (conceptor permission).

[teamId, '$canRefine', attributeId]

This allows team members to create facts like [attributeId, 'somePredicate', someOtherAttributeId].

$canReferTo

Grants permission to use an attribute as the object in facts (referrer permission).

[teamId, '$canReferTo', attributeId]

This allows team members to create facts like [otherId, 'somePredicate', attributeId].

Permission Hierarchy

Creator Permissions

When you create an attribute, you automatically get full permissions:

  • You become accountable for it
  • You can read and write it
  • You can use it as subject or object in facts
  • You can grant permissions to others

Group Permissions

Permissions can be granted to groups (teams), which then flow to all members:

// Create a team. A team is just an attribute.
const team = await lr.Attribute.createKeyValue({ name: 'Engineering' });
 
// Create a document with team access
const doc = await lr.Attribute.createKeyValue({ title: 'Spec' }, [
  ['$it', 'isA', 'Document'],
  [team.id, '$canAccess', '$it'],  // Team can read/write
]);
 
// Add a user to the team
const userId = await lr.getUserIdByEmail('engineer@example.com');
await lr.Fact.createAll([
  [userId, '$isMemberOf', team.id],
]);
// Now this user can access the document

Authorization Rules

Who Can Create Facts?

PredicateWho Can Create
$isATermForAnyone (terms are public domain). If a term already exists it will not be created again.
$isAccountableForAuto-created for creator; can transfer to a group you're a member of (not to users directly)
$isMemberOfCreator/accountable of the group, or anyone with $isHostOf on the group
$isHostOfCreator/accountable of the group, or anyone with $isHostOf on the group
$canReadAnyone accountable for the object, or hosts of groups with $canAccess to the object
$canAccessAnyone accountable for the object, or hosts of groups with $canAccess to the object
$canRefineAnyone accountable for the object, or hosts of groups with $canAccess to the object
$canReferToAnyone accountable for the object, or hosts of groups with $canAccess to the object
Custom predicatesAnyone with $canRefine on subject AND $canReferTo on object (includes group members with $canAccess)

All the above relations are not transitive.

Who Can Delete Facts?

Generally, you can delete facts you created or have appropriate authorization over:

  • Term facts can only be deleted by their creator
  • Membership facts can be deleted by hosts or the member themselves
  • Permission facts can be deleted by the accountable party

Practical Examples

Private Data (Default)

// This is only visible to you
const myNotes = await lr.Attribute.createKeyValue({
  title: 'Personal Notes',
  content: 'Secret stuff...',
});

Share with a Specific User

// Share read access with alice
const aliceId = await lr.getUserIdByEmail('alice@example.com');
await lr.Fact.createAll([
  [aliceId, '$canRead', myNotes.id],
]);
 
// Or share read+write access
await lr.Fact.createAll([
  [aliceId, '$canAccess', myNotes.id],
]);

Share with a Team

// Create a team and share with it
const team = await lr.Attribute.createKeyValue({ name: 'Marketing' }, [
  ['$it', 'isA', 'Team'],
]);
 
await lr.Attribute.createKeyValue(
  { title: 'Campaign Plan' },
  [
    ['$it', 'isA', 'Document'],
    [team.id, '$canAccess', '$it'],
  ]
);
 
// Add team members
const member1 = await lr.getUserIdByEmail('member1@example.com');
const member2 = await lr.getUserIdByEmail('member2@example.com');
 
await lr.Fact.createAll([
  [member1, '$isMemberOf', team.id],
  [member2, '$isMemberOf', team.id],
]);

Revoke Access

// Remove user from team
await lr.Fact.deleteAll([
  [userId, '$isMemberOf', teamId],
]);
 
// Remove direct access
await lr.Fact.deleteAll([
  [userId, '$canRead', documentId],
]);

Security Considerations

Users Cannot Self-Promote

A user cannot grant themselves permissions they don't have:

// This will fail - nemo can't add himself to a team
await nemo.Fact.createAll([
  [nemoId, '$isMemberOf', teamId],
]);
 
// Only the team creator or a host can add members
await teamCreator.Fact.createAll([
  [nemoId, '$isMemberOf', teamId],
]);

Members Cannot Invite Others (Only Hosts)

Being a member doesn't grant invitation rights:

// Aquaman creates a team and adds Nemo as a member
await aquaman.Fact.createAll([[nemoId, '$isMemberOf', teamId]]);
 
// Nemo tries to invite Manni - this fails
await nemo.Fact.createAll([[manniId, '$isMemberOf', teamId]]);
 
// Only works if Nemo is made a host first
await aquaman.Fact.createAll([[nemoId, '$isHostOf', teamId]]);
await nemo.Fact.createAll([[manniId, '$isMemberOf', teamId]]); // Now works

Custom Predicates Cannot Start with $

The $ prefix is reserved for system predicates:

// This fact will be silently ignored
await lr.Fact.createAll([
  [docId, '$myCustomPredicate', 'value'],
]);
 
// Use predicates without $
await lr.Fact.createAll([
  [docId, 'myCustomPredicate', 'value'],
]);

Accountability Transfer Rules

Accountability ($isAccountableFor) has specific transfer rules:

  • You can only transfer accountability to a group you're a member of (not to individual users or arbitrary entities)
  • Accountability cannot be deleted, only transferred
  • You cannot transfer accountability to terms or random strings
// Create an org and become a member
const org = await lr.Attribute.createKeyValue({ name: 'Acme' }, [
  ['$it', 'isA', 'Organization'],
]);
await lr.Fact.createAll([[myUserId, '$isMemberOf', org.id]]);
 
// Now you can transfer accountability of your document to the org
await lr.Fact.createAll([[org.id, '$isAccountableFor', doc.id]]);
 
// This would fail - can't transfer to a user directly
await lr.Fact.createAll([[otherUserId, '$isAccountableFor', doc.id]]);

Derived Permissions from Group Membership

When a group has $canAccess to an attribute, members of that group inherit implicit permissions:

  • Read and write access to the attribute's payload
  • Implicit $canRefine - can use the attribute as subject in custom facts (if group has $canRefine)
  • Implicit $canReferTo - can use the attribute as object in custom facts (if group has $canReferTo)
// Team has $canAccess to the project
await lr.Fact.createAll([[teamId, '$canAccess', projectId]]);
 
// Team member can now create custom facts using the project
// (because $canAccess implies ability to refine and refer)
await teamMember.Fact.createAll([
  [projectId, 'status', 'active'],  // Works - can use projectId as subject
  [taskId, 'belongsTo', projectId], // Works - can use projectId as object
]);

This means that for creating custom predicates, users with $canAccess through group membership effectively have $canRefine and $canReferTo permissions on those attributes.

Verifying Fact Creation

Since unauthorized fact creations are silently ignored, check the return value:

const created = await lr.Fact.createAll([
  [userId, '$isMemberOf', teamId],
]);
 
if (created.length === 0) {
  console.log('Not authorized to add user to team');
}