# Technical Interfaces

Interface Layering

The ACL design uses two distinct interface layers that must not be conflated.

Domain interfaces — I<Entity>

Pure domain contracts with no external dependencies. They represent the logical model and can be used anywhere in the codebase regardless of the underlying data store.

Examples: IUser, IDossier, IDocument, IGroup

These interfaces express what an entity is. Collections and relationships are modelled as direct properties (e.g. Dossier.resources: Resource[]).

Neo4j entity interfaces — INeo4j<Entity>

Graph-database–specific contracts that extend or parallel the domain interfaces. They reflect the constraints and conventions of Neo4j: nodes have flat property maps, and relationships are not node attributes — they are edges in the graph.

Examples: INeo4jUser, INeo4jDossier, INeo4jDocument, INeo4jGroup

Important — Dossier.resources vs. Neo4j graph edges:

The domain interface Dossier exposes a resources: Resource[] collection for convenience. In Neo4j this collection does not exist as a property on the Dossier node. It is materialized at query time by traversing outgoing [:CONTAINS] relationships:

cypher (d:Dossier)-[:CONTAINS]->(child:Document) (d:Dossier)-[:CONTAINS]->(child:Dossier)

INeo4jDossier therefore has no resources property. Children are fetched separately via getChildren(dossierId).

The same pattern applies to any other relationship-backed collection: the domain interface may expose it as a list, while the Neo4j layer resolves it through graph traversal.


Resource Entities & Service

Resource = documents, dataset and folders

Interfaces

type DossierType =
  | 'root'     // root hidden dossier of a tenant — managed by the system
  | 'user'     // user hidden dossier of a user — managed by the system
  | 'session'  // user's session hidden dossier — managed by the system
  | 'visible'; // visible dossier created by the user

interface Resource {
  id: string;

  // parentId and ownerGroupId are NOT properties on the node.
  // Parent dossier is resolved via (Dossier)-[:CONTAINS]->(Resource).
  // Ownership is resolved via (Group)-[:OWNS]->(Resource).

  isProtected: boolean; // break inheritance

  createdAt: Date;
  updatedAt: Date;
  isDeleted: boolean;
  deletedAt: Date | null;
  deletedBy: string | null;
}

interface Document extends Resource {}

interface Dossier extends Resource {
  type: DossierType;
  resources: Resource[];
}

Resource Service

CreateDossier(
  parentId: string,
  name: string,
  ownerGroupId: string,
  type: DossierType
): Promise<Resource>;

createDocument(
  parentId: string,
  name: string,
  ownerGroupId: string
): Promise<Resource>;

moveResource(
  resourceId: string,
  newParentId: string,
  effectiveUserId: string
): Promise<void>;

renameResource(
  resourceId: string,
  newName: string
): Promise<void>;

deleteResource(
  resourceId: string,
  effectiveUserId: string
): Promise<void>;

restoreResource(resourceId: string): Promise<void>;

purgeResource(resourceId: string): Promise<void>; // hard-delete after recovery window (cron)

getResource(resourceId: string): Promise<Resource>;

getChildren(resourceId: string): Promise<Resource[]>;

setProtected(resourceId: string, effectiveUserId: string): Promise<void>;

unprotect(resourceId: string, effectiveUserId: string): Promise<void>;

User & Group Entities

interface User {
  id: string;
  email: string;
  fullname: string;
  username: string;
  // Neo4j authorization node uses labels :User:SystemAcl.
  // Hidden system group: navigate via (User:SystemAcl)-[:MEMBER_OF]->(Group:SystemAcl {isUserGroup:true})
  // Hidden user dossier: navigate via (Group)-[:OWNS]->(Dossier {type:'user'})
}

interface Group {
  id: string;
  name: string;
  isUserGroup: boolean;    // true for hidden single-user groups — managed entirely by the system
  // Neo4j authorization node uses labels :Group:SystemAcl.
  // Imported diagram/entity groups remain :Group:Entity and are outside ACL.
  // tenant group carries NO property flag. It is identified by graph traversal:
  // (Group:SystemAcl)-[:HAS_ROLE]->(:UserRole:SystemAcl)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'})
}

Group Service

addMember(groupId: string, userId: string, effectiveUserId: string): Promise<void>;

removeMember(groupId: string, userId: string, effectiveUserId: string): Promise<void>;

getGroups(userId: string): Promise<Group[]>;

getMembers(groupId: string): Promise<User[]>;

// Called internally by UserService.addUser — creates the hidden system group (isUserGroup=true)
// and user dossier (type='user'), linking them via MEMBER_OF and OWNS edges.
provisionHiddenGroupAndDossierForUser(user: User): Promise<void>;

ACL Entities

type Permission =
  | 'READ'
  | 'SHARING'
  | 'DELETE'
  | 'ADD'
  | 'UPDATE'
  | 'OWNERSHIP';

type RoleType =
  | 'SUPER_ADMIN'
  | 'OWNER'
  | 'ADMIN'
  | 'MANAGER'
  | 'VIEWER';

interface Role {
  name: RoleType;
  permissions: Permission[];
}

// Only groups can own or be granted permissions on resources.
// Users interact with resources exclusively through their group membership.
// Every user has a hidden system group (isUserGroup: true) containing only themselves,
// reached via (User:SystemAcl)-[:MEMBER_OF]->(Group:SystemAcl {isUserGroup:true}).
//
// NOTE — Role is a C# abstraction only and is NEVER stored in Neo4j.
// When an ACLEntry is persisted, one [:GRANT:<PERMISSION>] typed relationship is created
// per effective permission (e.g. [:GRANT:READ], [:GRANT:SHARING]).
// When read back, the permission set is reconstructed from the typed relationships.

interface ACLEntry {
  resourceId: string;

  groupId: string; // always a Group ID — users are never granted directly

  role: Role;
  permissionOverrides?: Permission[]; // null = use role's canonical permissions; [] = deny all

  // Derived: permissionOverrides if set, otherwise role.permissions
  readonly effectivePermissions: Permission[];

  updatedAt: Date;
  grantedBy?: string; // effective user who created this grant
}

ACL Service

// Returns true if the user holds the specified permission on the resource.
// Evaluates inherited permissions (via CONTAINS ancestry) and SUPER_ADMIN bypass.
checkAccess(
  userId: string,
  resourceId: string,
  permission: Permission
): Promise<boolean>;

// Returns the full set of permissions the user holds on the resource
// after evaluating inheritance, isProtected barriers, and SUPER_ADMIN bypass.
getEffectivePermissions(
  userId: string,
  resourceId: string
): Promise<Permission[]>;

// Returns all ACL entries directly assigned to the resource (does not include inherited).
getACL(resourceId: string): Promise<ACLEntry[]>;

// Replaces ALL ACL entries on the resource atomically.
// Requires SHARING permission — caller is responsible for checking before calling.
setACL(
  resourceId: string,
  entries: ACLEntry[],
  effectiveUserId: string
): Promise<void>;

// Upserts a single grant for the entry's group on the resource (MERGE semantics).
// Requires SHARING permission — caller is responsible for checking before calling.
addPermission(entry: ACLEntry, effectiveUserId: string): Promise<void>;

// Removes all [:GRANT:<PERMISSION>] relationships for the group on the resource.
// No-op if no grant exists. Requires SHARING permission.
removePermission(
  resourceId: string,
  groupId: string,
  effectiveUserId: string
): Promise<void>;

Audit Entities & Service

type AuditEventType =
  | 'RESOURCE_CREATED'
  | 'RESOURCE_MOVED'
  | 'RESOURCE_RENAMED'
  | 'RESOURCE_DELETED'
  | 'RESOURCE_RESTORED'
  | 'PERMISSION_GRANTED'
  | 'PERMISSION_REVOKED'
  | 'RESOURCE_PROTECTED'
  | 'RESOURCE_UNPROTECTED'
  | 'OWNERSHIP_CHANGED'
  | 'SHARING_ACTION_PERFORMED'
  | 'ACCESS_DENIED'
  | 'ACCESS_GRANTED'
  | 'SUPER_ADMIN_ACCESS_USED'
  | 'GROUP_MEMBERSHIP_CHANGED'
  | 'USER_REMOVED_FROM_TENANT';

interface AuditEvent {
  id: string;
  type: AuditEventType;

  effectiveUserId: string;         // user who performed the action
  resourceId?: string;     // resource involved (if applicable)
  targetId?: string;       // secondary target (e.g. new parent, group, subject)

  metadata?: Record<string, unknown>; // additional context (e.g. old name, permission)

  occurredAt: Date;
}

Audit Service

log(event: Omit<AuditEvent, 'id' | 'occurredAt'>): Promise<void>;