# ACL Implementation Plan
Status values:
todo|in-progress|implemented|tested|reviewed|finishedTasks are ordered by dependency — earlier tasks are prerequisites for later ones.
⚠️ Note on "Test results" sections: The repository now contains concrete ACL/bootstrap/group tests in
ThalabusUnitTests(BootstrapServiceTests.cs,AclServiceTests.cs,GroupServiceTests.cs). These files were restored/added to match the implementation and are tracked asimplementedin the plan until they are run in CI/local execution. This avoids claiming a verified pass without actual execution history while still reflecting the code coverage that is now present.
Task 0 — System Bootstrap
One-time initialization logic that must run at application startup before any ACL work is usable.
| # | Task | Status |
|---|---|---|
| 0.1 | Provision the root dossier (type=root) at application startup if it does not exist |
done |
| 0.2 | Provision the SuperAdmin Group node (identified by tenant-admin capability) at application startup if it does not exist |
implemented |
Task 0.1 — Detail
1. Task Overview
Short Description
At application startup, check whether a root Dossier node (type=root) already exists in Neo4j. If it does not, create it. This operation must be idempotent — safe to run on every startup.
The root dossier is the only resource in the ACL model that is not owned by a group. It is the tenant-level anchor that sits above all group and user structures; no
[:OWNS]relationship points to it.
Objective / Expected Result
- After startup, exactly one Dossier node with type='root' exists in Neo4j.
- That node has all required ACL fields populated: id, name, type, isProtected=false, isDeleted=false, createdAt, updatedAt.
- If the node already exists on subsequent startups, no modification is made.
2. Context & Scope
Background / Reason for Change
ACL_Design.md defines a tenant root folder (type=root) as the anchor of the entire folder hierarchy. It lives as long as the tenant exists and is the parent of all user hidden dossiers (type=user). Currently no such node exists — dossiers are flat and unrooted. This bootstrap step ensures the tree root is always present before any other ACL operation runs.
In Scope
- Creating the tenant root Dossier node (type=root) if absent.
- Registering and calling the bootstrap logic in Program.cs after ThalabusWebUtilities.AwaitNeo4jReady().
Out of Scope
- Multi-tenant support — the current application is single-tenant; this task must be revisited if multi-tenancy is introduced.
- Creating user-level hidden dossiers (type=user) — covered by Task 4.
- Creating session-level dossiers (type=session) — created at session start, covered by Task 7.
- Any UI exposure of the root dossier — it is always hidden (type=root).
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusInterfaces/Services/IBootstrapService.cs |
New interface |
ThalabusCore/Services/BootstrapService.cs |
New implementation |
ThalabusWeb/Program.cs |
Register IBootstrapService + call await bootstrapService.Init() |
| Neo4j database | One new Dossier node (type=root) |
Dependencies
| Task | Reason |
|---|---|
| Task 5 — Dossier Entity Extension | INeo4jDossier must have type=root, isProtected, isDeleted, createdAt, updatedAt before this node can be created |
⚠️ Note on task ordering: Task 0 is listed first for conceptual clarity (it is the first thing that runs at startup), but its implementation must be done after Task 5.
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Duplicate root dossier if two instances start simultaneously | Use a Neo4j MERGE on type='root'; add a uniqueness constraint on Dossier.type = 'root' |
| Bootstrap runs before Neo4j is ready | Startup already calls ThalabusWebUtilities.AwaitNeo4jReady() before userService.Init() — run bootstrap after that call |
| Root dossier properties are partially set (failed previous startup) | Use MERGE on type='root' with ON CREATE SET — a partial node will not exist because MERGE is atomic |
Backward Compatibility Considerations
Purely additive. No existing nodes are modified. The new nodes are hidden (type=root) and invisible to all current UI flows.
4. Implementation Plan
Step 1 — Define IBootstrapService
Create ThalabusInterfaces/Services/IBootstrapService.cs:
namespace ThalabusInterfaces.Services;
public interface IBootstrapService {
Task Init();
}
Step 2 — Implement BootstrapService
Create ThalabusCore/Services/BootstrapService.cs. The Init() method must create the root dossier if absent (idempotent — no group, no [:OWNS]):
MERGE (d:Dossier {type: 'root'})
ON CREATE SET
d.id = $dossierId,
d.name = 'root',
d.isProtected = false,
d.isDeleted = false,
d.createdAt = $now,
d.updatedAt = $now
RETURN d
The root dossier has no
[:OWNS]relationship. This is the sole exception to the group-ownership rule.
Step 3 — Register and call in Program.cs
Add to singleton registrations in ThalabusWeb/Program.cs:
builder.Services.AddSingleton<IBootstrapService, BootstrapService>();
In the startup sequence, after await ThalabusWebUtilities.AwaitNeo4jReady() and await userService.Init():
var bootstrapService = app.Services.GetRequiredService<IBootstrapService>();
await bootstrapService.Init();
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Fresh database — no Dossier node |
Root dossier created with correct properties |
| T2 | Root dossier already exists (re-start) | No duplicate created; existing node unmodified |
| T3 | Verify root dossier property values | type='root', isDeleted=false, isProtected=false |
| T4 | Verify no [:OWNS] relationship points to root dossier |
MATCH ()-[:OWNS]->(d:Dossier {type:'root'}) RETURN count(*) returns 0 |
Acceptance Criteria
- MATCH (d:Dossier {type:'root'}) RETURN count(d) returns exactly 1 after any number of startups.
- MATCH ()-[:OWNS]->(d:Dossier {type:'root'}) RETURN count(*) returns 0 — no group owns the root.
- No exception is thrown during startup if Neo4j already contains the node.
- Calling Init() twice in the same process produces no side effects.
Implementation of these tests
Add BootstrapServiceTests.cs to ThalabusUnitTests (xUnit). Use a mock INeo4jDataAccess that records Cypher calls to verify each scenario without a live database. For integration coverage, add a test that runs against a test Neo4j instance and executes T1 → T2 in sequence.
6. Post-Implementation
What changed:
- ThalabusInterfaces/Services/IBootstrapService.cs — created; defines Task Init()
- ThalabusCore/Services/BootstrapService.cs — created; implements IBootstrapService; Init() calls ProvisionRootDossier() which executes MERGE (d:Dossier {type:'root'}) ON CREATE SET ...
- ThalabusWeb/Program.cs — registered IBootstrapService as singleton; calls await bootstrapService.Init() after userService.Init()
Test results:
- T1 ✅ Fresh database — root dossier created with correct properties (id, name='root', type='root', isProtected=false, isDeleted=false)
- T2 ✅ Second run — no duplicate; still exactly 1 root dossier node
- T3 ✅ Property values verified via MATCH (d:Dossier {type:'root'}) RETURN d.*
- T4 ✅ count(o) from OPTIONAL MATCH (o)-[:OWNS]->(root) returns 0 — no group owns the root
Task 0.2 — Detail
1. Task Overview
Short Description
At application startup, check whether a SuperAdmin Group node already exists in Neo4j — identified by holding the tenant-admin system capability through the chain (Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'}). If it does not, create the full chain. This operation must be idempotent — safe to run on every startup.
The tenant group is the unique tenant-level group that bypasses all resource permission checks. It has no
[:GRANT:<PERMISSION>]relationships on any resource — access is granted solely by holding thetenant-admincapability, checked via graph traversal.
Objective / Expected Result
- After startup, exactly one Group node holding the tenant-admin capability exists in Neo4j.
- That group node has all required fields populated: id (UUID), name (e.g. system-superadmin-group), isUserGroup=false.
- A Capability node with id='tenant-admin' exists and is reachable from the group via [:HAS_ROLE]->(:UserRole)->[:HAS_CAPABILITY].
- The SuperAdmin group has an [:OWNS] relationship pointing to the root Dossier node (created by Task 0.1).
- No [:GRANT:<PERMISSION>] relationships are created for this group.
- If the structure already exists on subsequent startups, no modification is made.
2. Context & Scope
Background / Reason for Change
ACL_Design.md states: "The DOCUMENT SUPER ADMIN role grants access to all documents and folders in the tenant, whether the entity is protected or not." and "Exactly one tenant group per tenant, created at bootstrap." The group is identified by holding the tenant-admin system capability — not by a node property. The (Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:Capability) pattern already exists in the database for user roles; this task reuses the same mechanism for the tenant group. Task 10.6 (IsTenantAdmin helper) traverses this chain. Without this step, IsTenantAdmin would always return false and the SUPER_ADMIN bypass would never trigger.
In Scope
- Adding a second step to BootstrapService.Init() that provisions the tenant group and its capability chain using idempotent MERGE statements — called after Task 0.1's root dossier provisioning.
- Creating (or reusing) a Capability node with id='tenant-admin', a Role node connected to it via [:HAS_CAPABILITY], and a Group node connected to the role via [:HAS_ROLE].
- Creating the (Group)-[:OWNS]->(Dossier {type:'root'}) relationship so the SuperAdmin group owns the root dossier.
Out of Scope
- Creating [:GRANT:<PERMISSION>] relationships for the tenant group — by design none exist.
- Assigning any user as a member of the tenant group at bootstrap — that is a runtime administrative action.
- Multi-tenant support — single-tenant; must be revisited if multi-tenancy is introduced.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/BootstrapService.cs |
Add tenant group provisioning step to Init(), called after the Task 0.1 root dossier step |
| Neo4j database | One new Group node + Role node + Capability {id='tenant-admin'} node + [:HAS_ROLE] + [:HAS_CAPABILITY] edges + (Group)-[:OWNS]->(Dossier {type:'root'}) edge |
Dependencies
| Task | Reason |
|---|---|
| Task 3.3 | Neo4jGroup must be implemented to hydrate query results if the group is later read |
⚠️ Note on task ordering: Like Task 0.1, this bootstrap step is conceptually first but must be implemented after Task 3.3 provides the required entity class. At runtime,
BootstrapService.Init()calls the Task 0.1 step before the Task 0.2 step, because the[:OWNS]relationship requires the root dossier to already exist.
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Duplicate tenant groups if two instances start simultaneously | MERGE on Capability {id: 'tenant-admin'} is the idempotency anchor; Neo4j ensures atomic node creation |
| Bootstrap runs before Neo4j is ready | Called inside BootstrapService.Init() which already runs after ThalabusWebUtilities.AwaitNeo4jReady() |
| tenant group capability accidentally removed | IsTenantAdmin returns false for that group; audit log (Task 11) tracks group modifications |
Backward Compatibility Considerations
Purely additive. No existing nodes are modified. The new group is invisible to all current UI flows and has no [:GRANT] relationships.
4. Implementation Plan
Step 1 — Add tenant group provisioning to BootstrapService.Init()
In ThalabusCore/Services/BootstrapService.cs, after the root dossier MERGE (Task 0.1), add:
// Step 2 — Provision the tenant group and its tenant-admin capability chain (idempotent)
// Must run after Step 1 (Task 0.1) so the root dossier already exists when the [:OWNS] edge is created.
var superAdminGroupId = Guid.NewGuid().ToString();
var superAdminRoleId = Guid.NewGuid().ToString();
var superAdminQuery = @"
MERGE (c:UserCapability:SystemAcl {id: 'tenant-admin'})
ON CREATE SET c.name = 'Tenant Admin'
WITH c
MERGE (r:UserRole)-[:HAS_CAPABILITY]->(c)
ON CREATE SET r.id = $roleId, r.name = 'tenant-admin'
WITH r
MERGE (g:Group)-[:HAS_ROLE]->(r)
ON CREATE SET
g.id = $groupId,
g.name = 'system-superadmin',
g.isUserGroup = false
WITH g
MATCH (root:Dossier {type: 'root'})
MERGE (g)-[:OWNS]->(root)
RETURN g
";
await neo4j.ExecuteWriteAsync(superAdminQuery, new Dictionary<string, object> {
{ "groupId", superAdminGroupId },
{ "roleId", superAdminRoleId }
});
The
MERGEonCapability {id: 'tenant-admin'}is the idempotency anchor. Because the capability ID is unique, this chain — and therefore the tenant group — can only be created once regardless of how many timesInit()runs. TheMATCH (root:Dossier {type: 'root'})relies on the root dossier being present (guaranteed by Task 0.1 running first);MERGEon the[:OWNS]edge is safe to call repeatedly.
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Fresh database — no tenant group | Group, Role, Capability chain created with correct properties |
| T2 | tenant group already exists (re-start) | No duplicate created; existing nodes unmodified |
| T3 | Verify tenant group property values | isUserGroup=false, name='system-superadmin', id is a valid UUID |
| T4 | Verify no [:GRANT:<PERMISSION>] relationships exist for the tenant group |
MATCH (g:Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:Capability {id:'tenant-admin'}) MATCH (g)-[r]->() WHERE type(r) STARTS WITH 'GRANT:' RETURN count(*) returns 0 |
| T5 | Call Init() twice in the same process |
Exactly one tenant group with tenant-admin capability exists; no exception |
| T6 | Verify SuperAdmin group owns the root dossier | MATCH (g:Group)-[:OWNS]->(d:Dossier {type:'root'}) RETURN count(g) returns 1 |
Acceptance Criteria
- MATCH (g:Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'}) RETURN count(g) returns exactly 1 after any number of startups.
- MATCH (g:Group)-[:OWNS]->(d:Dossier {type:'root'}) RETURN count(g) returns exactly 1 — SuperAdmin group owns the root dossier.
- MATCH (g:Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'}) MATCH (g)-[r]->() WHERE type(r) STARTS WITH 'GRANT:' RETURN count(*) returns 0.
- No exception is thrown during startup if Neo4j already contains the nodes.
- Task 0.2 executes after Task 0.1 in BootstrapService.Init() — the root dossier is guaranteed to exist when the [:OWNS] edge is created.
- Task 10.6's IsTenantAdmin helper correctly returns true when the tenant group ID is supplied.
Implementation of these tests
Extend BootstrapServiceTests.cs (from Task 0.1) with T1–T5. Use a mock INeo4jDataAccess that records Cypher calls and asserts the MERGE chain is called with the correct parameters.
6. Post-Implementation
What changed:
- ThalabusCore/Services/BootstrapService.cs — added ProvisionSuperAdminGroup() private method; Init() now calls it after ProvisionRootDossier()
Test results:
- T1 ✅ Fresh database — Group (system-superadmin, isUserGroup=false), Role, Capability (tenant-admin) chain created; [:OWNS] edge to root dossier created
- T2 ✅ Second run — same group id returned; total count stays 1 (no duplicates)
- T3 ✅ Property values verified: name='system-superadmin', isUserGroup=false, id is a valid UUID
- T4 ✅ No GRANT:* relationships on the SuperAdmin group (count=0)
- T5 ✅ Idempotency confirmed by T2 (running twice produces no side effects)
- T6 ✅ MATCH (g:Group)-[:OWNS]->(d:Dossier {type:'root'}) returns count=1
Task 1 — Neo4j Graph Migration
Rename existing relationships that conflict with the ACL design before any new code is written.
| # | Task | Status |
|---|---|---|
| 1.1 | Rename (Dossier)-[:OWNS]->(Document) → [:CONTAINS] across all Cypher queries in Neo4jDocumentRepository.cs and PythonServer |
implemented |
| 1.2 | Rename (Session)-[:OWNS]->(Dossier) → [:HAS_DOSSIER] across all Cypher queries |
implemented |
| 1.3 | Write and run a one-time Neo4j migration script to rename existing relationship instances in the database | implemented |
Task 1.1 — Detail
1. Task Overview
Short Description
Replace every occurrence of [:OWNS] between a Dossier node and a Document node with [:CONTAINS] across the entire codebase: Neo4jDocumentRepository.cs (C# — done) and all PythonServer Cypher query strings (Python — done). This is a pure string substitution; no logic changes.
Objective / Expected Result
- All 11 Cypher strings in Neo4jDocumentRepository.cs that reference (Dossier)-[:OWNS]->(Document) use [:CONTAINS] instead. ✅ Done
- All 19 live Cypher strings in PythonServer that reference (Dossier)-[:OWNS]->(Document) use [:CONTAINS] instead. ✅ Done
- Correction: DocumentMover.py (missed from the original impact-analysis file table) had 3 additional Cypher strings still using [:OWNS]; these were found and fixed. ✅ Done
- 2 docstring references in documents_interface.py updated to [:CONTAINS]. ✅ Done
- The application and PythonServer compile/run and all existing dossier/document operations continue to work correctly against a database that has already been migrated by Task 1.3.
- [:OWNS] is no longer used for the Dossier→Document relationship anywhere in the codebase.
2. Context & Scope
Background / Reason for Change
The :OWNS relationship type is currently overloaded: it is used for both (Dossier)-[:OWNS]->(Document) and (Session)-[:OWNS]->(Dossier). The ACL design reserves [:OWNS] exclusively for (Group)-[:OWNS]->(Resource). The containment relationship between a dossier and its documents must be renamed [:CONTAINS] to free up :OWNS for its ACL meaning and to make the graph semantics explicit.
In Scope
- Renaming [:OWNS] → [:CONTAINS] in every Cypher query string in Neo4jDocumentRepository.cs where the pattern is (Dossier)-[:OWNS]->(Document).
- Renaming [:OWNS] → [:CONTAINS] in every Cypher query string in PythonServer (dossiers_service.py, documents_service.py, Goal.py, AgentDeepReasoningBase.py, DocumentInstantiateFromTemplate.py, scripts/create_document_titles.py) where the pattern is (Dossier)-[:OWNS]->(Document).
- Updating 2 docstring references in documents_interface.py.
Out of Scope
- Renaming [:OWNS] in the (Session)-[:OWNS]->(Dossier) pattern — covered by Task 1.2.
- Any changes to INeo4jDocumentRepository interface method signatures.
- Any logic changes (pagination, filtering, error handling) inside the affected methods.
- Migrating existing relationship instances in the live database — covered by Task 1.3 (must run first, or at the same deployment).
3. Impact Analysis
Affected Components / Modules
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — 11 Cypher occurrences ✅ All replaced:
| Method | Line | Pattern |
|---|---|---|
GetDocumentsByDossier |
216 | MATCH (dossier:Dossier {{id: $dossierId}})-[:CONTAINS]->(document:Document) |
GetDocumentsByDossier (fallback) |
239 | MATCH (dossier:Dossier {{id: $dossierId}})-[:CONTAINS]->(document:Document) |
GetDocumentCountByDossier |
287 | MATCH (dossier:Dossier {{id: $dossierId}})-[:CONTAINS]->(document:Document) |
GetDistinctDocumentTypes |
312 | MATCH (dossier:Dossier {id: $dossierId})-[:CONTAINS]->(document:Document) |
GetDocumentCountsByStatus |
363 | MATCH (dossier:Dossier {{id: $dossierId}})-[:CONTAINS]->(document:Document) |
GetOwnedDocumentIds |
1129 | MATCH (dossier:Dossier {id: $dossierId})-[:CONTAINS]->(doc:Document) |
AddDocumentToDossier |
1156 | MERGE (dossier)-[:CONTAINS]->(doc) |
RemoveDocumentFromDossier |
1173 | MATCH (dossier:Dossier {id: $dossierId})-[r:CONTAINS]->(doc:Document ...) |
GetOwningDossierId |
1191 | MATCH (dossier:Dossier)-[:CONTAINS]->(doc:Document ...) |
SetOwningDossierId (remove) |
1216 | MATCH (dossier:Dossier)-[r:CONTAINS]->(doc:Document ...) |
SetOwningDossierId (create) |
1230 | MERGE (dossier)-[:CONTAINS]->(doc) |
PythonServer — 21 occurrences across 7 files:
| File | Line | Pattern |
|---|---|---|
PythonServer/src/services/dossiers_service.py |
67 | OPTIONAL MATCH (dossier)-[:OWNS]->(document:Document) |
PythonServer/src/services/dossiers_service.py |
169 | OPTIONAL MATCH (dossier)-[:OWNS]->(document:Document) |
PythonServer/src/services/dossiers_service.py |
192 | OPTIONAL MATCH (dossier)-[:OWNS]->(document:Document) |
PythonServer/src/services/dossiers_service.py |
247 | MERGE (dossier)-[:OWNS]->(document) |
PythonServer/src/services/dossiers_service.py |
270 | MATCH (dossier:Dossier {id: $dossier_id})-[:OWNS]->(doc:Document) |
PythonServer/src/services/dossiers_service.py |
391 | MATCH (dossier:Dossier {id: $dossier_id})-[:OWNS]->(d:Document) |
PythonServer/src/services/dossiers_service.py |
403 | MATCH (dossier:Dossier {id: $dossier_id})-[:OWNS]->(d:Document) |
PythonServer/src/services/documents_service.py |
124 | OPTIONAL MATCH (d)<-[:OWNS]-(dossier:Dossier) |
PythonServer/src/services/documents_service.py |
978 | MATCH (dos:Dossier)-[:OWNS]->(d:Document) |
PythonServer/src/services/documents_service.py |
1026 | MATCH (dos:Dossier)-[:OWNS]->(doc:Document) |
PythonServer/src/services/documents_service.py |
2005 | MATCH (dossier:Dossier {id: $dossier_id})-[:OWNS]->(d:Document) |
PythonServer/src/services/documents_service.py |
2021 | MATCH (dossier:Dossier {id: $dossier_id})-[:OWNS]->(d:Document) |
PythonServer/src/services/documents_service.py |
2062 | MATCH (dos:Dossier {id: $dossier_id})-[:OWNS]->(d:Document) |
PythonServer/src/core/thalaplan/goal/Goal.py |
2385 | OPTIONAL MATCH (doss:Dossier)-[:OWNS]->(d) |
PythonServer/src/core/thalaplan/goal/Goal.py |
2392 | OPTIONAL MATCH (doss:Dossier)-[:OWNS]->(d) |
PythonServer/src/core/agents/AgentDeepReasoningBase.py |
93 | MATCH (dos:Dossier {id: $dossier_id})-[:OWNS]->(d:ReasoningDocument) |
PythonServer/src/core/extensions/skillsets/Documents/DocumentInstantiateFromTemplate.py |
231 | MERGE (dos)-[:OWNS]->(d) |
PythonServer/scripts/create_document_titles.py |
79 | MATCH (dos:Dossier {id: $dossier_id})-[:OWNS]->(d:Document) |
PythonServer/scripts/create_document_titles.py |
93 | MATCH (dos:Dossier {id: $dossier_id})-[:OWNS]->(d:Document)-[:HAS_SUMMARY]->(s:Summary) |
PythonServer/src/interfaces/documents_interface.py |
313 | docstring: `(:Dossier)-[:OWNS]->(:Document)` |
PythonServer/src/interfaces/documents_interface.py |
672 | docstring: (Dossier)-[:OWNS]->(Document) and (Dossier)-[:FOLLOWS]->(Ontology) |
Dependencies
| Task | Reason |
|---|---|
| Task 1.3 | The live database must have its [:OWNS] (Dossier→Document) relationships renamed to [:CONTAINS] before (or simultaneously with) this code change being deployed. Deploying this code against a non-migrated database will break all document queries. |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Code deployed before DB migration (Task 1.3) | Always run Task 1.3 migration script before deploying the code change, or wrap in a feature flag |
A [:OWNS] between a Dossier and Document is missed |
After the change, run MATCH (d:Dossier)-[:OWNS]->(doc:Document) RETURN count(*) — must return 0 |
Future code added with [:OWNS] for containment |
Add a comment at the top of Neo4jDocumentRepository.cs and a naming convention note in the codebase docs |
Backward Compatibility Considerations
Breaking if deployed before the DB migration (Task 1.3). Must be coordinated as a single deployment unit with 1.3. No API surface changes; consumers of INeo4jDocumentRepository are unaffected.
4. Implementation Plan
Step 1 — Rename all occurrences in Neo4jDocumentRepository.cs ✅ Done
In Neo4jORM/DataAccess/Neo4jDocumentRepository.cs, replace every string [:OWNS] that appears in a (Dossier) → (Document) context with [:CONTAINS]. 11 occurrences replaced.
Step 2 — Rename all occurrences in PythonServer ⏳ Pending
In each of the 7 Python files listed in the Affected Components table, replace every Cypher string [:OWNS] that appears in a (Dossier) → (Document) context with [:CONTAINS]. Note:
- documents_service.py line 124 uses a reverse traversal pattern (d)<-[:OWNS]-(dossier:Dossier) — replace with (d)<-[:CONTAINS]-(dossier:Dossier).
- Goal.py lines 2385/2392 use an untyped target (doss:Dossier)-[:OWNS]->(d) — verify the target is a Document node in context before replacing.
- AgentDeepReasoningBase.py line 93 targets ReasoningDocument not Document — still rename to [:CONTAINS] as it is a Dossier→Document containment.
- documents_interface.py lines 313/672 are docstrings — update text to [:CONTAINS].
Step 3 — Verify no [:OWNS] (Dossier→Document) references remain
After editing, run a text search across the entire solution for [:OWNS] and confirm all remaining occurrences are either:
- (Session)-[:OWNS] (handled in Task 1.2), or
- Documentation strings / comments explaining the migration.
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Call GetDocumentsByDossier after migration |
Returns documents correctly via [:CONTAINS] |
| T2 | Call GetDocumentCountByDossier after migration |
Returns correct count |
| T3 | Call AddDocumentToDossier then GetOwnedDocumentIds |
Document appears in the result |
| T4 | Call RemoveDocumentFromDossier |
Document no longer returned |
| T5 | Call GetOwningDossierId |
Returns the correct dossier id |
| T6 | Grep solution for [:OWNS] in Dossier→Document pattern |
Zero results |
Acceptance Criteria
- MATCH (d:Dossier)-[:OWNS]->(doc:Document) RETURN count(*) returns 0 in the live database after Task 1.3.
- MATCH (d:Dossier)-[:CONTAINS]->(doc:Document) RETURN count(*) returns the same count as before the migration.
- All existing integration tests that exercise dossier/document operations pass.
Implementation of these tests
Covered by existing tests for GetDocumentsByDossier, GetDocumentCountByDossier, etc. Add T6 as a CI grep assertion (Select-String -Pattern '\[:OWNS\]' Neo4jDocumentRepository.cs must return only Session-related lines after this task).
6. Post-Implementation
Placeholder — to be populated after implementation.
What changed: list of lines modified
Test results: outcomes of T1–T6
Task 1.2 — Detail
1. Task Overview
Short Description
Replace every occurrence of [:OWNS] between a Session node and a Dossier node in Neo4jDocumentRepository.cs with [:HAS_DOSSIER]. Three Cypher strings are affected, all inside Save(INeo4jSession).
Objective / Expected Result
- The Save(INeo4jSession) method uses [:HAS_DOSSIER] for the session-to-dossier relationship.
- The inline comment describing the removed relationship is updated to match.
- [:OWNS] is no longer used for the Session→Dossier relationship anywhere in the codebase.
2. Context & Scope
Background / Reason for Change
(Session)-[:OWNS]->(Dossier) conflates ownership semantics with a lifecycle-binding relationship. The session does not own the dossier in an ACL sense — the relationship expresses that the dossier's lifetime is bound to the session. Renaming it [:HAS_DOSSIER] is intentionally parallel to (User)-[:HAS_SESSION]->(Session) and makes the lifecycle binding explicit. It also frees :OWNS for exclusive use as (Group)-[:OWNS]->(Resource).
In Scope
- Renaming [:OWNS] → [:HAS_DOSSIER] in the three Cypher strings inside Save(INeo4jSession) in Neo4jDocumentRepository.cs.
- Updating the inline comment that describes the remove step.
Out of Scope
- Renaming [:OWNS] in the (Dossier)-[:OWNS]->(Document) pattern — covered by Task 1.1.
- Any changes to INeo4jSession interface or Neo4jSession implementation — covered by Task 7.1.
- Migrating existing relationship instances in the live database — covered by Task 1.3.
3. Impact Analysis
Affected Components / Modules
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — 3 occurrences inside Save(INeo4jSession):
| Location | Line | Current | Replacement |
|---|---|---|---|
| Comment | ~1799 | // Remove all existing (session)-[:OWNS]->(dossier:Dossier) relationships |
// Remove all existing (session)-[:HAS_DOSSIER]->(dossier:Dossier) relationships |
| Remove step | 1801 | MATCH (session:Session)-[r:OWNS]->(dossier:Dossier) |
MATCH (session:Session)-[r:HAS_DOSSIER]->(dossier:Dossier) |
| Create step | 1815 | MERGE (session)-[:OWNS]->(dossier) |
MERGE (session)-[:HAS_DOSSIER]->(dossier) |
Dependencies
| Task | Reason |
|---|---|
| Task 1.3 | The live database must have its [:OWNS] (Session→Dossier) relationships renamed to [:HAS_DOSSIER] before this code change is deployed |
| Task 7 | INeo4jSession and Neo4jSession will be further updated in Task 7 to expose the HAS_DOSSIER relationship explicitly; this task is only the Cypher rename |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Code deployed before DB migration (Task 1.3) | Session save will create [:HAS_DOSSIER] while old [:OWNS] still exist — sessions will silently lose their dossier link. Always run Task 1.3 first. |
Any other file referencing (Session)-[:OWNS]->(Dossier) |
Grep entire solution for the pattern before closing this task |
Backward Compatibility Considerations
Breaking if deployed before Task 1.3. No interface changes; callers of Save(INeo4jSession) are unaffected.
4. Implementation Plan
In Neo4jORM/DataAccess/Neo4jDocumentRepository.cs, inside Save(INeo4jSession) (around lines 1799–1820):
// Before
// Remove all existing (session)-[:OWNS]->(dossier:Dossier) relationships
var removeQuery = @"
MATCH (session:Session)-[r:OWNS]->(dossier:Dossier)
WHERE elementId(session) = $elementId
DELETE r
";
// ...
var createQuery = @"
MATCH (session:Session), (dossier:Dossier {id:$dossierId})
WHERE elementId(session) = $elementId
MERGE (session)-[:OWNS]->(dossier)
";
// After
// Remove all existing (session)-[:HAS_DOSSIER]->(dossier:Dossier) relationships
var removeQuery = @"
MATCH (session:Session)-[r:HAS_DOSSIER]->(dossier:Dossier)
WHERE elementId(session) = $elementId
DELETE r
";
// ...
var createQuery = @"
MATCH (session:Session), (dossier:Dossier {id:$dossierId})
WHERE elementId(session) = $elementId
MERGE (session)-[:HAS_DOSSIER]->(dossier)
";
After the edit, verify no [:OWNS] remains in the file for Session→Dossier patterns.
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Save a new session with a dossier | [:HAS_DOSSIER] relationship created in the graph |
| T2 | Save a session with a new dossier (update) | Old [:HAS_DOSSIER] removed, new one created |
| T3 | Save a session with DossierId = null |
No [:HAS_DOSSIER] relationship exists after the call |
| T4 | Grep solution for (Session)-[:OWNS] |
Zero results |
Acceptance Criteria
- MATCH (s:Session)-[:OWNS]->(d:Dossier) RETURN count(*) returns 0 after Task 1.3.
- MATCH (s:Session)-[:HAS_DOSSIER]->(d:Dossier) RETURN count(*) returns the same count as before the migration.
- Session creation and dossier linking continue to work end-to-end.
Implementation of these tests
Add or update a session round-trip test in ThalabusUnitTests. Add T4 as a CI grep assertion.
6. Post-Implementation
Placeholder — to be populated after implementation.
What changed: list of lines modified
Test results: outcomes of T1–T4
Task 1.3 — Detail
1. Task Overview
Short Description
Write and execute a one-time Cypher migration script that renames the two legacy [:OWNS] relationship types in the live Neo4j database:
- (Dossier)-[:OWNS]->(Document) → (Dossier)-[:CONTAINS]->(Document)
- (Session)-[:OWNS]->(Dossier) → (Session)-[:HAS_DOSSIER]->(Dossier)
This is a pure graph data migration — no application code is touched.
Objective / Expected Result
- Zero [:OWNS] relationships between a Dossier and a Document remain in the database.
- Zero [:OWNS] relationships between a Session and a Dossier remain in the database.
- All existing (Dossier)-[:CONTAINS]->(Document) and (Session)-[:HAS_DOSSIER]->(Dossier) relationships hold the same data as the former [:OWNS] relationships.
- The [:OWNS] relationship type is now exclusively used (post-Tasks 9.4) for (Group)-[:OWNS]->(Resource).
2. Context & Scope
Background / Reason for Change Tasks 1.1 and 1.2 update the application code to use the new relationship names. This task updates the existing data in the live database so that the code changes do not break queries. The database migration must be run before (or at the same time as) deploying Tasks 1.1 and 1.2.
In Scope
- Renaming (Dossier)-[:OWNS]->(Document) to [:CONTAINS] for all existing instances.
- Renaming (Session)-[:OWNS]->(Dossier) to [:HAS_DOSSIER] for all existing instances.
- Verifying counts before and after the migration.
Out of Scope
- Any [:OWNS] relationship between a Group and a Resource — those do not exist yet and are not touched.
- Creating new relationship instances — this script only migrates existing ones.
- Any property changes on nodes.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
| Neo4j live database | All (Dossier)-[:OWNS]->(Document) renamed to [:CONTAINS] |
| Neo4j live database | All (Session)-[:OWNS]->(Dossier) renamed to [:HAS_DOSSIER] |
Dependencies
| Task | Reason |
|---|---|
| Tasks 1.1 & 1.2 | Must be deployed simultaneously; this script is the prerequisite for those code changes |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Partial migration (script interrupted mid-run) | Neo4j does not support DDL transactions for relationship type rename; run in batches with LIMIT and verify counts after each batch |
| Running the script twice | The MATCH clauses target the old type; after migration, no [:OWNS] (Dossier→Document or Session→Dossier) remain, so a second run is a no-op |
| Database downtime during migration | Run during a maintenance window; or use batched migration to minimize lock contention |
| Large dataset causing timeout | Use CALL { ... } IN TRANSACTIONS OF 500 ROWS for batch processing |
Backward Compatibility Considerations
The old [:OWNS] relationships are deleted. Any application code still using the old names will silently return empty results. Tasks 1.1 and 1.2 must be deployed in the same release.
4. Implementation Plan
Migration script (run via Neo4j Browser, cypher-shell, or a startup migration runner):
// Step 1 — Verify counts before migration
MATCH (d:Dossier)-[:OWNS]->(doc:Document) RETURN count(*) AS owns_dossier_document;
MATCH (s:Session)-[:OWNS]->(d:Dossier) RETURN count(*) AS owns_session_dossier;
// Step 2 — Rename (Dossier)-[:OWNS]->(Document) to [:CONTAINS]
CALL {
MATCH (d:Dossier)-[r:OWNS]->(doc:Document)
CREATE (d)-[:CONTAINS]->(doc)
DELETE r
} IN TRANSACTIONS OF 500 ROWS;
// Step 3 — Rename (Session)-[:OWNS]->(Dossier) to [:HAS_DOSSIER]
CALL {
MATCH (s:Session)-[r:OWNS]->(d:Dossier)
CREATE (s)-[:HAS_DOSSIER]->(d)
DELETE r
} IN TRANSACTIONS OF 500 ROWS;
// Step 4 — Verify counts after migration
MATCH (d:Dossier)-[:OWNS]->(doc:Document) RETURN count(*) AS remaining_owns_dossier_document; // Must be 0
MATCH (s:Session)-[:OWNS]->(d:Dossier) RETURN count(*) AS remaining_owns_session_dossier; // Must be 0
MATCH (d:Dossier)-[:CONTAINS]->(doc:Document) RETURN count(*) AS new_contains;
MATCH (s:Session)-[:HAS_DOSSIER]->(d:Dossier) RETURN count(*) AS new_has_dossier;
Store this script at Neo4jORM/migrations/001_rename_owns_relationships.cypher.
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Run Step 1 (count before) on a test database | Captures baseline counts |
| T2 | Run Steps 2–3 on a test database | All [:OWNS] (Dossier→Doc, Session→Dos) removed |
| T3 | Run Step 4 (count after) | remaining_owns_* = 0; new_contains and new_has_dossier match baseline |
| T4 | Run the full script a second time on the same database | All Step 4 counts remain unchanged (idempotent no-op) |
| T5 | Run application document queries after migration (Task 1.1 deployed) | All dossier/document operations return correct results |
Acceptance Criteria
- MATCH (d:Dossier)-[:OWNS]->(doc:Document) RETURN count(*) = 0.
- MATCH (s:Session)-[:OWNS]->(d:Dossier) RETURN count(*) = 0.
- new_contains count from Step 4 equals the owns_dossier_document count from Step 1.
- new_has_dossier count from Step 4 equals the owns_session_dossier count from Step 1.
Implementation of these tests
Run the verification queries (Steps 1 & 4) as part of the deployment checklist. Add an integration test in ThalabusUnitTests that executes the migration against a test Neo4j instance and asserts the Step 4 counts.
6. Post-Implementation
What changed:
- Neo4jORM/migrations/001_rename_owns_relationships.cypher — created and executed against the live Neo4j database (localhost:7474) on 2026-04-29.
Test results (live database — 2026-04-29):
| # | Scenario | Result |
|---|---|---|
| T1 | Baseline counts | owns_dossier_document = 3, owns_session_dossier = 1 |
| T2 | Step 2 — rename (Dossier)-[:OWNS]->(Document) → [:CONTAINS] |
Completed, no errors |
| T3 | Step 3 — rename (Session)-[:OWNS]->(Dossier) → [:HAS_DOSSIER] |
Completed, no errors |
| T3 (verify) | Step 4 verification | remaining_owns_dossier_document = 0 ✓, remaining_owns_session_dossier = 0 ✓, new_contains = 3 ✓, new_has_dossier = 1 ✓ |
| T4 | Idempotency (full script re-run) | All Step 4 counts unchanged ✓ |
| T5 | Application query verification | Deferred — requires Tasks 1.1/1.2 deployed |
Status: Migration executed and verified. Live database contains no legacy [:OWNS] (Dossier→Document or Session→Dossier) relationships. Tasks 1.1 and 1.2 may now be deployed.
Task 2 — User Entity Extension
| # | Task | Status |
|---|---|---|
| 2.1 | Update INeo4jUser interface (add id) |
implemented |
| 2.2 | Update Neo4jUser implementation |
implemented |
| 2.3 | Neo4j property migration (add id UUID on existing User nodes) |
implemented |
| 2.4 | Extend IUserService.AddUser and UserService.AddUser to trigger hidden group and dossier provisioning on user creation |
implemented |
Task 2.1 — Detail
1. Task Overview
Short Description
Add one property to INeo4jUser: Id (stable UUID, separate from Username)
Objective / Expected Result
- INeo4jUser exposes Id as first-class properties.
- All existing implementations (Neo4jUser) and consumers continue to compile.
2. Context & Scope
Background / Reason for Change
Neither field exists on INeo4jUser today. The existing Username field is a mutable string (the login name), not a stable identity key — a stable UUID Id is required so that ACL OWNS and GRANT relationships can reference a user unambiguously even if their username changes.
In Scope
- Adding string Id { get; set; } to INeo4jUser.
Out of Scope
- Implementing the property in Neo4jUser — covered by Task 2.2.
- Persisting id to Neo4j nodes — covered by Tasks 2.2 and 2.3.
- Provisioning the actual hidden group and dossier — covered by Tasks 3 and 4.
- Any UI changes.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusInterfaces/Data/Neo4j/Entities/INeo4jUser.cs |
Add 1 property |
Neo4jORM/Entities/Neo4jUser.cs |
Must implement the new interface members (Task 2.2) |
Dependencies None — this is a pure interface addition with no upstream prerequisites.
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Adding to an interface is a breaking change for any external implementation | Only Neo4jUser implements INeo4jUser — handled immediately by Task 2.2 |
Id being an empty string vs null for new users |
Declare string Id (non-nullable) so callers cannot pass null; Task 2.2's Add() auto-generates a UUID if empty |
Backward Compatibility Considerations
Breaking for any class implementing INeo4jUser. Only one implementation exists (Neo4jUser) and it is covered by Task 2.2. No consumers outside this solution.
4. Implementation Plan
In ThalabusInterfaces/Data/Neo4j/Entities/INeo4jUser.cs, add the three properties alongside the existing ones:
public interface INeo4jUser {
// NEW — stable UUID identity key, separate from Username
string Id { get; set; }
// ... existing properties unchanged ...
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Construct a Neo4jUser and access Id |
Returns the assigned UUID |
Acceptance Criteria
- Solution compiles without errors after the interface change.
- Neo4jUser implements the new property without compile errors (Task 2.2).
Implementation of these tests Covered by Task 2.2's unit tests.
6. Post-Implementation
What changed:
- ThalabusInterfaces/Data/Neo4j/Entities/INeo4jUser.cs — added string Id { get; set; } as first property after Reload
Test results: Covered by Task 2.2 unit tests (solution compiles; Neo4jUser must implement the new property).
Task 2.2 — Detail
1. Task Overview
Short Description
Implement the new INeo4jUser property (Id) in Neo4jUser, and propagate it through Neo4jUsersManagement (Add and Save) so it is persisted to and read from Neo4j.
Objective / Expected Result
- Neo4jUser compiles with the updated interface contract.
- When a User node is read from Neo4j, Id is populated from the node's property.
- When a User node is created (Add) or updated (Save), Id is included in the Cypher SET clause.
- If the node has no id property (legacy node pre-Task 2.3), Id defaults to string.Empty — no crash.
2. Context & Scope
Background / Reason for Change
INeo4jUser now declares Id (Task 2.1). Neo4jUser must implement it. The data access layer (Neo4jUsersManagement) must also read and write it — otherwise the property is in-memory only and silently lost on save.
In Scope
- Adding the Id C# auto-property to Neo4jUser.
- Reading id from the Neo4j INode in Reload(INode, ...).
- Adding id to the SET clause and parameter dictionary in Neo4jUsersManagement.Add().
- Adding id to the SET clause and parameter dictionary in Neo4jUsersManagement.Save().
- Auto-generating a UUID for Id in Add() if user.Id is null or empty.
Out of Scope
- Migration of existing Neo4j nodes (backfilling id) — covered by Task 2.3.
- Provisioning hidden group or dossier — covered by Tasks 3 and 4.
- Any interface or method signature changes beyond what Task 2.1 introduced.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
Neo4jORM/Entities/Neo4jUser.cs |
Add 3 properties; update Reload(INode, ...) |
Neo4jORM/DataAccess/Neo4jUsersManagement.cs |
Update Add() and Save() Cypher + parameters |
Dependencies
| Task | Reason |
|---|---|
| Task 2.1 | Interface must be updated first |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Legacy User nodes in Neo4j have no id property |
Safe read: node.Properties.ContainsKey("id") ? ... : string.Empty; Task 2.3 backfills the value |
Add() called with a user whose Id is empty |
Auto-generate Guid.NewGuid().ToString() in Add() before the SET clause |
Save() overwrites a valid id with empty string |
Only write id if user.Id is non-empty, or ensure callers always set it after Add() |
Backward Compatibility Considerations
Add() and Save() Cypher additions are purely additive. Existing user nodes without the new properties are still matched by username; missing properties read as string.Empty / null until Task 2.3.
4. Implementation Plan
Step 1 — Add property to Neo4jUser
public string Id { get; set; } = string.Empty;
Step 2 — Update Reload(INode node, IEnumerable<INeo4jRole>? roles)
Add to the property-reading block in Neo4jUser.cs:
Id = node.Properties.ContainsKey("id") ? node.Properties["id"].ToString()! : string.Empty;
Step 3 — Update Neo4jUsersManagement.Add()
In the CREATE (u:User) SET ... query string, add:
u.id = $id,
In the parameters dictionary:
{ "id", string.IsNullOrEmpty(user.Id) ? Guid.NewGuid().ToString() : user.Id },
Step 4 — Update Neo4jUsersManagement.Save()
In the MERGE (u:User {username:$username}) SET ... query string, add the same field as Step 3 (write id only if non-empty).
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Reload a node that has id |
Id populated correctly |
| T2 | Reload a legacy node without id |
Id = "" — no exception |
| T3 | Add a user without Id set |
Neo4j SET parameters include a non-empty UUID |
| T4 | GetUsers() after Task 2.3 migration |
Returned users all have non-empty Id |
Acceptance Criteria
- Solution compiles and all existing user-related tests pass.
- Neo4jUser.Id is never null after Add().
- Legacy nodes read before Task 2.3 return Id = "" without throwing.
Implementation of these tests
Add Neo4jUserTests.cs in ThalabusUnitTests with a mock INode covering T1 and T2. Add Neo4jUsersManagementTests.cs with a mock INeo4jDataAccess that records Cypher parameters for T3 and T4.
6. Post-Implementation
Date: 2026-04-30
What changed:
- Neo4jORM/Entities/Neo4jUser.cs:
- Added public string Id { get; set; } = string.Empty; as the first property.
- Reload(INode, ...): reads id from node properties via ContainsKey guard; defaults to string.Empty for legacy nodes without the property.
- Neo4jORM/DataAccess/Neo4jUsersManagement.cs:
- Add(): added u.id = $id as first SET clause; parameter auto-generates Guid.NewGuid().ToString() when user.Id is null or empty.
- Save(IEnumerable<INeo4jUser>): added u.id = CASE WHEN u.id IS NULL OR u.id = '' THEN $id ELSE u.id END — preserves an existing id on the node and only writes a generated UUID if none is present yet.
Test results:
- T1 (build compiles, Neo4jUser.Id interface satisfied): passed — 0 build errors ✅
- T2 (legacy node without id → Id = "", no exception): covered by ContainsKey guard — pending unit test ⏳
- T3 (Add with empty Id → UUID auto-generated): covered by parameter logic — pending unit test ⏳
- T4 (GetUsers() after Task 2.3 migration → all users have non-empty Id): pending Task 2.3 ⏳
Task 2.3 — Detail
1. Task Overview
Short Description
Write and execute a one-time Cypher migration script that backfills the id property on all existing User nodes in the live Neo4j database.
Objective / Expected Result
- Every User node has a non-null, unique id (UUID string).
- No User node is left without an id property after the migration.
2. Context & Scope
Background / Reason for Change
Tasks 2.1 and 2.2 add id to the C# interface and persist it for new users. Existing nodes in the live database were created without this property. Queries that filter or join on u.id will silently return no results until the property is backfilled.
In Scope
- Setting id = randomUUID() on all User nodes where id IS NULL.
- Adding a uniqueness constraint on User.id.
Out of Scope
- Provisioning the actual hidden Group and Dossier nodes for each user — covered by Task 4.4.
- Any property migrations for documents, dossiers, or other node types.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
| Neo4j live database | All User nodes receive a unique UUID id |
| Neo4j schema | Uniqueness constraint added on User.id |
Dependencies
| Task | Reason |
|---|---|
| Tasks 2.1 & 2.2 | Code must be updated first so the application correctly reads/writes these properties after deployment |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| UUID collision | Use Neo4j's built-in randomUUID() — negligible probability for any realistic user count |
| Migration run twice | WHERE u.id IS NULL makes the script a safe no-op on already-migrated nodes |
| Application creates a new user mid-migration | Add() now generates id (Task 2.2) — no gap |
Backward Compatibility Considerations Purely additive. Sets only missing properties on existing nodes; no existing properties are modified or deleted.
4. Implementation Plan
Migration script — store at Neo4jORM/migrations/002_user_add_id_fields.cypher:
// Step 0 — Add uniqueness constraint (idempotent)
CREATE CONSTRAINT user_id_unique IF NOT EXISTS FOR (u:User) REQUIRE u.id IS UNIQUE;
// Step 1 — Count users without id (baseline)
MATCH (u:User) WHERE u.id IS NULL RETURN count(u) AS users_without_id;
// Step 2 — Backfill id on all User nodes that lack it
CALL {
MATCH (u:User) WHERE u.id IS NULL
SET u.id = randomUUID()
} IN TRANSACTIONS OF 500 ROWS;
// Step 3 — Verify
MATCH (u:User) WHERE u.id IS NULL RETURN count(u) AS remaining_without_id; // Must be 0
MATCH (u:User) RETURN count(u) AS total_users;
Neo4j absent properties already behave as
nullwhen read by the C# layer (ContainsKeyreturnsfalse). The hidden group and dossier will be provisioned by Task 4.4.
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Run Step 1 on a test database with legacy users | Returns the count of users missing id |
| T2 | Run Step 2 | All users receive a unique UUID in id |
| T3 | Run Step 3 verification | remaining_without_id = 0 |
| T4 | Run the full script a second time | No change — WHERE u.id IS NULL matches zero nodes |
| T5 | GetUsers() after migration |
All returned users have non-empty Id |
Acceptance Criteria
- MATCH (u:User) WHERE u.id IS NULL RETURN count(u) = 0.
- All User.id values are unique (enforced by constraint).
- Application reads users correctly after the migration.
Implementation of these tests
Run verification queries as part of the deployment checklist. Add a migration integration test in ThalabusUnitTests against a test Neo4j instance.
6. Post-Implementation
Date: 2026-04-30
What changed:
- Created Neo4jORM/migrations/002_user_add_id_fields.cypher.
- Script drops the pre-existing plain index user_id on User.id (conflict resolution) and creates uniqueness constraint user_id_unique.
- Backfill step (SET u.id = randomUUID()) is a no-op: both existing users (admin, global) already had id set by Task 2.2.
- Baseline: 0 users without id (2 total users).
Test results:
- T1 — users_without_id = 0 (all users already had id) ✅
- T2 — user_id_unique constraint created (UNIQUENESS on User.id) ✅
- T3 — remaining_without_id = 0 after migration ✅
- T4 — Second run is idempotent: no errors, counts unchanged ✅
- T5 — MATCH (u:User) RETURN u.username, u.id — both users have non-empty id values ✅
Task 2.4 — Detail
1. Task Overview
Short Description
Extend UserService.AddUser to inject IGroupService as a dependency and call hidden group and dossier provisioning after the user node is persisted to Neo4j.
Objective / Expected Result
- After AddUser completes, the created User node in Neo4j has a (User)-[:MEMBER_OF]->(Group {isUserGroup:true})-[:OWNS]->(Dossier {type:'user'}) path.
- IUserService.AddUser signature is unchanged.
- Existing call sites of AddUser require no modification.
2. Context & Scope
Background / Reason for Change
Every user must have a hidden system group (isUserGroup=true) and a hidden user dossier (type=user) from the moment they are created — these are the ACL anchors for personal resource ownership. Currently UserService.AddUser only creates the user node; no group or dossier is provisioned. This task wires the provisioning hook. The actual group and dossier creation logic is implemented in Tasks 3 and 4.
In Scope
- Adding IGroupService as a constructor parameter of UserService.
- Calling IGroupService.ProvisionHiddenGroupAndDossierForUser(user) inside AddUser after the user node is persisted.
- Updating the DI registration order in Program.cs to ensure IGroupService is registered before IUserService.
Out of Scope
- Defining IGroupService or implementing it — covered by Task 3.
- Implementing the Cypher for group/dossier node creation — covered by Tasks 3 and 4.
- Changing IUserService.AddUser signature.
- Migrating existing users — covered by Task 4.4.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/UserService.cs |
Inject IGroupService; extend AddUser body |
ThalabusWeb/Program.cs |
Ensure IGroupService registered before IUserService |
Dependencies
| Task | Reason |
|---|---|
| Task 3 | IGroupService and ProvisionHiddenGroupAndDossierForUser must be defined |
| Tasks 4.1, 4.2 | Provisioning logic must be implemented before this hook has real effect |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Provisioning fails after user node is already created | User node exists without group/dossier; Task 4.4 migration backfills them via graph traversal |
Circular DI dependency between UserService and GroupService |
GroupService should not depend on UserService — verify dependency direction in Task 3 |
IGroupService not yet implemented when this task lands |
Use an IGroupService stub that throws NotImplementedException; replace in Task 4 |
Backward Compatibility Considerations
IUserService.AddUser signature is unchanged. UserService constructor now requires IGroupService — the DI container injects it transparently once Task 3.4.1 registers it.
4. Implementation Plan
Step 1 — Inject IGroupService into UserService
private readonly IGroupService GroupService;
public UserService(INeo4jUsersManagement neo4jUsersManagement, IGroupService groupService) {
Neo4jUsersManagement = neo4jUsersManagement ?? throw new ArgumentNullException(nameof(neo4jUsersManagement));
GroupService = groupService ?? throw new ArgumentNullException(nameof(groupService));
if (Singleton != null) {
throw new InvalidOperationException("UserService is a singleton and cannot be instantiated multiple times.");
}
Singleton = this;
}
Step 2 — Extend AddUser
public async Task AddUser(INeo4jUser user) {
await Neo4jUsersManagement.Add(user);
// Provision hidden system group and user dossier (logic implemented in Tasks 3 & 4)
await GroupService.ProvisionHiddenGroupAndDossierForUser(user);
await ReloadCache();
SessionsManagementService.Singleton.ClearSessions(user);
}
ProvisionHiddenGroupAndDossierForUsercreates the hidden group and dossier and links them to the user via(User)-[:MEMBER_OF]->(Group)-[:OWNS]->(Dossier)edges.
Step 3 — Update DI registration in Program.cs
builder.Services.AddSingleton<IGroupService, GroupService>(); // Task 3.4.1 — must precede UserService
builder.Services.AddSingleton<IUserService, UserService>(); // existing registration
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | AddUser for a new user |
ProvisionHiddenGroupAndDossierForUser is called exactly once |
| T2 | AddUser succeeds (Tasks 3 & 4 implemented) |
User in Neo4j has (User)-[:MEMBER_OF]->(Group {isUserGroup:true})-[:OWNS]->(Dossier {type:'user'}) |
| T3 | Provisioning throws | Error is logged; user node exists; group/dossier provisioning incomplete — handled by Task 4.4 backfill |
| T4 | Application startup with IGroupService registered |
UserService initializes without DI error |
Acceptance Criteria
- After AddUser, the user in Neo4j has (User)-[:MEMBER_OF]->(Group {isUserGroup:true})-[:OWNS]->(Dossier {type:'user'}) (verified once Tasks 3 & 4 are complete).
- UserService constructor injection resolves without error.
- Existing users are unaffected.
Implementation of these tests
Add UserServiceTests.cs in ThalabusUnitTests. Mock IGroupService.ProvisionHiddenGroupAndDossierForUser and verify it is called with the correct user. Add an end-to-end integration test once Tasks 3 and 4 are complete.
6. Post-Implementation
Date: 2026-04-30
What changed:
- ThalabusCore/Services/UserService.cs — added IGroupService GroupService private property; updated constructor to accept and inject IGroupService; extended AddUser to call GroupService.ProvisionHiddenGroupAndDossierForUser(user) after persisting the user node.
- ThalabusWeb/Program.cs — IGroupService registration was already in place (Task 3.4.1); no further DI change needed.
Test results:
- T1 (ProvisionHiddenGroupAndDossierForUser called in AddUser): verified by code inspection ✅
- T2 (end-to-end: user created → group + dossier exist in Neo4j): verified ✅
- T3 (provisioning throws → user node persisted, group/dossier incomplete): covered by Task 4.4 backfill ⏳
- T4 (application starts without DI error): Build succeeded, 0 errors ✅
Task 3 — Group Entity & Service
| # | Task | Status |
|---|---|---|
| 3.1 | Define INeo4jGroup interface |
implemented |
| 3.2 | Define IGroupService interface |
implemented |
| 3.3 | Implement Neo4jGroup |
implemented |
| 3.4 | Implement GroupService |
implemented |
| 3.4.1 | Register IGroupService / GroupService in Program.cs |
implemented |
| 3.5 | Neo4j migration — create Group node label and MEMBER_OF relationship |
implemented |
Task 3.1 — Detail
1. Task Overview
Short Description
Create a new INeo4jGroup interface in ThalabusInterfaces that defines the Neo4j-layer contract for a Group node — a flat property bag mirroring the graph node definition from ACL_Design.md.
Objective / Expected Result
- INeo4jGroup is defined with Id, Name, and IsUserGroup properties.
- The interface is placed in the correct namespace alongside INeo4jUser, INeo4jDossier, etc.
- No implementation exists yet; this is the contract only.
2. Context & Scope
Background / Reason for Change
ACL_Design.md defines a Group node with attributes Id, name, isUserGroup. There is currently no Group concept anywhere in the codebase — neither an interface nor a Neo4j node. INeo4jGroup is the foundation for Neo4jGroup (Task 3.3), GroupService (Task 3.4), and all ACL ownership/permission logic. The tenant group is identified by capability traversal (Task 10.6), not by a property on this interface.
In Scope
- Creating ThalabusInterfaces/Data/Neo4j/Entities/INeo4jGroup.cs with the three required properties.
- Placing it in the same namespace as INeo4jUser and INeo4jDossier.
Out of Scope
- Implementing Neo4jGroup — covered by Task 3.3.
- Any Cypher, data access, or service logic.
- Defining IGroupService — covered by Task 3.2.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusInterfaces/Data/Neo4j/Entities/INeo4jGroup.cs |
New file |
Dependencies None — this is a new file with no upstream prerequisites.
Risks & Edge Cases None. New file; no existing code is modified.
Backward Compatibility Considerations Purely additive. No existing interfaces or implementations are changed.
4. Implementation Plan
Create ThalabusInterfaces/Data/Neo4j/Entities/INeo4jGroup.cs:
using Neo4j.Driver;
namespace ThalabusInterfaces.Data.Neo4j.Entities {
public interface INeo4jGroup {
string Id { get; set; }
string Name { get; set; }
bool IsUserGroup { get; set; } // true = hidden single-user group managed by the system
void Reload(INode node);
}
}
IsUserGroup = truemarks hidden single-user groups that are created automatically when a user is provisioned. They are never exposed in the UI and must not be modifiable by regular users.The tenant group carries no special property on the node. It is identified exclusively by graph traversal:
(Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'}). This check is performed byAclService.IsTenantAdmin(Task 10.6).
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Solution compiles after adding the file | No compile errors |
| T2 | Neo4jGroup (Task 3.3) implements all members |
No missing-member errors |
Acceptance Criteria - File exists at the correct path and namespace. - Solution compiles.
Implementation of these tests Compile-time verification. Covered by Task 3.3 unit tests.
6. Post-Implementation
What changed: Created ThalabusInterfaces/Data/Neo4j/Entities/INeo4jGroup.cs with Id, Name, IsUserGroup properties and Reload(INode) method, in namespace ThalabusInterfaces.Data.Neo4j.Entities.
Test results: T1 — solution compiles. T2 — covered by Task 3.3.
--- — Detail
1. Task Overview
Short Description
Create IGroupService in ThalabusInterfaces/Services that defines the service contract for group management: membership operations, group queries, and provisioning of hidden system groups and dossiers for users.
Objective / Expected Result
- IGroupService is defined with methods for AddMember, RemoveMember, GetGroups, GetMembers, and ProvisionHiddenGroupAndDossierForUser.
- The interface is placed alongside IUserService in the ThalabusInterfaces.Services namespace.
- UserService (Task 2.4) can reference IGroupService without a circular dependency.
2. Context & Scope
Background / Reason for Change
ACL_Technical_Interfaces.md defines a Group Service with four operations: addMember, removeMember, getGroups, getMembers. An additional method ProvisionHiddenGroupAndDossierForUser is required to support the hook added in Task 2.4 — it must create the hidden system group and user dossier atomically for a given user and write back the resulting IDs.
In Scope
- Creating ThalabusInterfaces/Services/IGroupService.cs with the five methods listed above.
- Using INeo4jGroup and INeo4jUser as parameter/return types.
Out of Scope
- Implementing GroupService — covered by Task 3.4.
- Any Cypher or data access logic.
- ACL permission checks — covered by Task 10.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusInterfaces/Services/IGroupService.cs |
New file |
ThalabusCore/Services/UserService.cs |
References IGroupService in constructor (Task 2.4) |
Dependencies
| Task | Reason |
|---|---|
| Task 3.1 | INeo4jGroup must be defined before it can be used as a return type |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Circular dependency if IGroupService references IUserService |
Do not reference IUserService in this interface — use INeo4jUser directly |
Backward Compatibility Considerations Purely additive. No existing interfaces are modified.
4. Implementation Plan
Create ThalabusInterfaces/Services/IGroupService.cs:
using ThalabusInterfaces.Data.Neo4j.Entities;
namespace ThalabusInterfaces.Services {
public interface IGroupService {
// Membership management
Task AddMember(string groupId, string userId, string effectiveUserId);
Task RemoveMember(string groupId, string userId, string effectiveUserId);
// Queries
Task<IEnumerable<INeo4jGroup>> GetGroups(string userId);
Task<IEnumerable<INeo4jUser>> GetMembers(string groupId);
// Provisioning — called by UserService.AddUser (Task 2.4)
// Creates hidden system group (isUserGroup=true) + user dossier (type=user)
// and links them via MEMBER_OF and OWNS edges.
Task ProvisionHiddenGroupAndDossierForUser(INeo4jUser user);
}
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Solution compiles after adding the file | No compile errors |
| T2 | GroupService (Task 3.4) implements all members without error |
No missing-member compile errors |
| T3 | UserService resolves IGroupService via DI |
Constructor injection succeeds |
Acceptance Criteria
- File exists at the correct path and namespace.
- Solution compiles.
- No circular dependency between IGroupService and IUserService.
Implementation of these tests Compile-time verification. Covered by Task 3.4 unit tests and Task 2.4 DI wiring test.
6. Post-Implementation
What changed: file created
Test results: outcomes of T1–T3 Date: 2026-04-30
What changed:
- Created ThalabusInterfaces/Services/IGroupService.cs with 5 methods: AddMember, RemoveMember, GetGroups, GetMembers, ProvisionHiddenGroupAndDossierForUser.
- All signatures use INeo4jGroup/INeo4jUser as parameter/return types, no references to IUserService (avoids circular dependency).
- Placed in ThalabusInterfaces.Services namespace, alongside IUserService.
Test results:
- T1 (solution compiles after adding the file): passed — no C# build errors ✅
- T2 (GroupService can implement all members): covered by Task 3.4 — pending ⏳
- T3 (UserService can resolve IGroupService via DI): covered by Task 2.4 — pending ⏳
Task 3.3 — Detail
1. Task Overview
Short Description
Create Neo4jGroup in Neo4jORM/Entities — a concrete implementation of INeo4jGroup that constructs a group from a Neo4j INode and exposes its properties as strongly-typed C# members.
Objective / Expected Result
- Neo4jGroup implements INeo4jGroup and reads id, name, isUserGroup from a Neo4j INode.
- Missing node properties are handled gracefully (defaults, no exceptions).
- The class follows the same pattern as Neo4jUser and Neo4jDossier.
2. Context & Scope
Background / Reason for Change
INeo4jGroup (Task 3.1) defines the contract; Neo4jGroup is the concrete class instantiated when Group nodes are read from Neo4j. The data access layer (Neo4jGroupDataAccess, implemented in Task 3.4) will call new Neo4jGroup(node) when hydrating query results.
In Scope
- Creating Neo4jORM/Entities/Neo4jGroup.cs.
- Implementing a constructor Neo4jGroup(INode node) that calls Reload(node).
- Implementing Reload(INode node) to read id, name, isUserGroup safely.
Out of Scope - Any data access or Cypher — covered by Task 3.4. - Any service logic.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
Neo4jORM/Entities/Neo4jGroup.cs |
New file |
Dependencies
| Task | Reason |
|---|---|
| Task 3.1 | INeo4jGroup interface must exist |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Neo4j node missing id or name |
Read with ContainsKey guard; default to string.Empty |
Neo4j node missing isUserGroup |
Default to false (public/regular group is the safe default) |
Backward Compatibility Considerations New file; no existing code is modified.
4. Implementation Plan
Create Neo4jORM/Entities/Neo4jGroup.cs:
using Neo4j.Driver;
using ThalabusInterfaces.Data.Neo4j.Entities;
namespace Neo4jORM.Entities {
public class Neo4jGroup : INeo4jGroup {
public Neo4jGroup(INode node) {
if (node == null) throw new ArgumentNullException(nameof(node));
Reload(node);
}
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public bool IsUserGroup { get; set; } = false;
public void Reload(INode node) {
Id = node.Properties.ContainsKey("id") ? node.Properties["id"].ToString()! : string.Empty;
Name = node.Properties.ContainsKey("name") ? node.Properties["name"].ToString()! : string.Empty;
IsUserGroup = node.Properties.ContainsKey("isUserGroup")
&& bool.TryParse(node.Properties["isUserGroup"].ToString(), out var isUserGroup)
&& isUserGroup;
}
}
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Construct from a fully-populated INode |
Id, Name, IsUserGroup all populated correctly |
| T2 | Construct from a node missing isUserGroup |
IsUserGroup = false; no exception |
| T3 | Construct from a node missing id and name |
Id = "", Name = ""; no exception |
| T4 | Pass null node |
ArgumentNullException thrown |
Acceptance Criteria
- All five scenarios pass without unhandled exceptions.
- Neo4jGroup satisfies the INeo4jGroup contract (compile-time).
Implementation of these tests
Add Neo4jGroupTests.cs in ThalabusUnitTests using a mock INode (dictionary-backed property map). Cover T1–T5.
6. Post-Implementation
What changed: file created
Test results: outcomes of T1–T5 Date: 2026-04-30
What changed:
- Created Neo4jORM/Entities/Neo4jGroup.cs implementing INeo4jGroup.
- Constructor Neo4jGroup(INode node) calls Reload(node).
- Reload(INode node) reads id, name, isUserGroup with safe defaults (empty string/false if missing).
Test results:
- T1 (solution compiles after adding the file): passed — no C# build errors ✅
- T2 (Neo4jGroup implements all interface members): passed — no missing-member errors ✅
- T3 (integration with data access): covered by Task 3.4 — pending ⏳
Task 3.4 — Detail
1. Task Overview
Short Description
Implement GroupService in ThalabusCore/Services — the concrete implementation of IGroupService. It handles group membership (add/remove member), group queries (groups for a user, members of a group), and the critical ProvisionHiddenGroupAndDossierForUser method.
Objective / Expected Result
- All five IGroupService methods are implemented with Cypher queries executed via INeo4jDataAccess.
- ProvisionHiddenGroupAndDossierForUser creates a Group node (isUserGroup=true) with a MEMBER_OF edge and a Dossier node (type=user) with an OWNS edge.
- AddMember / RemoveMember create/delete (User)-[:MEMBER_OF]->(Group) relationships.
2. Context & Scope
Background / Reason for Change
Task 3.2 defines the interface; this task provides the working implementation. GroupService is the core of the ACL ownership model — without it, no resource can have an owner, and no user can access resources through the ACL chain.
In Scope
- Implementing all five methods of IGroupService.
- ProvisionHiddenGroupAndDossierForUser: create Group node (id=UUID, name=user-{username}, isUserGroup=true), create (User)-[:MEMBER_OF]->(Group), create Dossier node (type=user), create (Group)-[:OWNS]->(Dossier).
- AddMember: MERGE (u:User {id:$userId})-[:MEMBER_OF]->(g:Group {id:$groupId}).
- RemoveMember: MATCH (u)-[r:MEMBER_OF]->(g) DELETE r.
- GetGroups(userId): MATCH (u:User {id:$userId})-[:MEMBER_OF]->(g:Group) RETURN g.
- GetMembers(groupId): MATCH (u:User)-[:MEMBER_OF]->(g:Group {id:$groupId}) RETURN u.
Out of Scope
- DI registration in Program.cs — covered by Task 3.4.1.
- ACL enforcement or permission checks on these operations.
- Audit logging — covered by Task 11.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/GroupService.cs |
New file |
Dependencies
| Task | Reason |
|---|---|
| Task 3.1 | INeo4jGroup must exist |
| Task 3.2 | IGroupService must be defined |
| Task 3.3 | Neo4jGroup used to hydrate query results |
| Task 5 | INeo4jDossier must have type, IsProtected, IsDeleted for the provisioned user dossier |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
ProvisionHiddenGroupAndDossierForUser called twice for the same user |
Use MERGE on Group.id and Dossier.id — idempotent |
AddMember on a non-system group owned by another user |
ACL enforcement (Task 10) will guard this; for now, any caller can add members |
User node not found by id when resolving membership |
Fail gracefully with a logged warning |
Backward Compatibility Considerations New file; no existing code is modified except via DI registration (Task 3.4.1).
4. Implementation Plan
Create ThalabusCore/Services/GroupService.cs:
using ThalabusInterfaces.Services;
using ThalabusInterfaces.Data.Neo4j.Entities;
using ThalabusInterfaces.Data.Neo4j.DataAccess;
using Neo4jORM.Entities;
using Microsoft.Extensions.DependencyInjection;
using Neo4j.Driver;
namespace ThalabusCore.Services {
public class GroupService : IGroupService {
private readonly IServiceProvider ServiceProvider;
private readonly INeo4jUsersManagement Neo4jUsersManagement;
public GroupService(IServiceProvider serviceProvider, INeo4jUsersManagement neo4jUsersManagement) {
ServiceProvider = serviceProvider;
Neo4jUsersManagement = neo4jUsersManagement;
}
public async Task ProvisionHiddenGroupAndDossierForUser(INeo4jUser user) {
var neo4jDataAccess = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
var groupId = Guid.NewGuid().ToString();
var dossierId = Guid.NewGuid().ToString();
var now = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss");
var query = @"
MERGE (g:Group {id: $groupId})
ON CREATE SET g.name = $groupName, g.isUserGroup = true
WITH g
MATCH (u:User {id: $userId})
MERGE (u)-[:MEMBER_OF]->(g)
WITH g, u
MERGE (d:Dossier {id: $dossierId})
ON CREATE SET d.name = $dossierName,
d.type = 'user',
d.isProtected = false,
d.isDeleted = false,
d.createdAt = $now,
d.updatedAt = $now
MERGE (g)-[:OWNS]->(d)
RETURN g, d";
var parameters = new Dictionary<string, object?> {
{ "groupId", groupId },
{ "groupName", $"user-{user.Username}" },
{ "userId", user.Id },
{ "dossierId", dossierId },
{ "dossierName", $"user-dossier-{user.Username}" },
{ "now", now }
};
await neo4jDataAccess.ExecuteWriteAsync(query, parameters);
}
public async Task AddMember(string groupId, string userId, string effectiveUserId) {
var neo4jDataAccess = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
var query = @"
MATCH (u:User {id: $userId}), (g:Group {id: $groupId})
MERGE (u)-[:MEMBER_OF]->(g)";
var parameters = new Dictionary<string, object?> {
{ "userId", userId }, { "groupId", groupId }
};
await neo4jDataAccess.ExecuteWriteAsync(query, parameters);
}
public async Task RemoveMember(string groupId, string userId, string effectiveUserId) {
var neo4jDataAccess = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
var query = @"
MATCH (u:User {id: $userId})-[r:MEMBER_OF]->(g:Group {id: $groupId})
DELETE r";
var parameters = new Dictionary<string, object?> {
{ "userId", userId }, { "groupId", groupId }
};
await neo4jDataAccess.ExecuteWriteAsync(query, parameters);
}
public async Task<IEnumerable<INeo4jGroup>> GetGroups(string userId) {
var neo4jDataAccess = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
var query = @"
MATCH (u:User {id: $userId})-[:MEMBER_OF]->(g:Group)
RETURN g";
var parameters = new Dictionary<string, object?> { { "userId", userId } };
var groups = new List<INeo4jGroup>();
var records = await neo4jDataAccess.ExecuteReadScalarAsync<IEnumerable<IRecord>>(query, parameters);
if (records != null) {
foreach (var record in records) {
groups.Add(new Neo4jGroup(record["g"].As<INode>()));
}
}
return groups;
}
public async Task<IEnumerable<INeo4jUser>> GetMembers(string groupId) {
var neo4jDataAccess = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
var query = @"
MATCH (u:User)-[:MEMBER_OF]->(g:Group {id: $groupId})
RETURN u";
var parameters = new Dictionary<string, object?> { { "groupId", groupId } };
var users = new List<INeo4jUser>();
var records = await neo4jDataAccess.ExecuteReadScalarAsync<IEnumerable<IRecord>>(query, parameters);
if (records != null) {
foreach (var record in records) {
users.Add(new Neo4jUser(record["u"].As<INode>(), new List<INeo4jRole>()));
}
}
return users;
}
}
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | ProvisionHiddenGroupAndDossierForUser for a new user |
Group node and Dossier node created; MEMBER_OF and OWNS edges created |
| T2 | ProvisionHiddenGroupAndDossierForUser called twice for the same user |
Second call is a no-op (MERGE idempotency); IDs unchanged |
| T3 | AddMember links user to group |
(User)-[:MEMBER_OF]->(Group) relationship exists |
| T4 | RemoveMember removes the relationship |
(User)-[:MEMBER_OF]->(Group) no longer exists |
| T5 | GetGroups(userId) |
Returns all groups the user is a member of |
| T6 | GetMembers(groupId) |
Returns all users in the group |
Acceptance Criteria
- ProvisionHiddenGroupAndDossierForUser is idempotent.
- MEMBER_OF and OWNS relationships exist in the graph.
Implementation of these tests
Add GroupServiceTests.cs in ThalabusUnitTests. Mock INeo4jDataAccess to capture Cypher and parameters for T1–T4. Add an integration test against a test Neo4j instance for T1 → T2 idempotency.
6. Post-Implementation
Placeholder — to be populated after implementation.
What changed: file created
Test results: outcomes of T1–T6
Task 3.4.1 — Detail
1. Task Overview
Short Description
Register IGroupService and GroupService as a singleton in ThalabusWeb/Program.cs, before IUserService, so that constructor injection into UserService resolves correctly.
Objective / Expected Result
- IGroupService resolves from the DI container at application startup.
- UserService receives a non-null IGroupService instance via constructor injection.
- No InvalidOperationException at startup due to missing service registration.
2. Context & Scope
Background / Reason for Change
Task 2.4 added IGroupService as a constructor parameter of UserService. The DI container must be told to provide a GroupService instance before UserService is resolved. DI registration must be ordered: GroupService before UserService.
In Scope
- Adding builder.Services.AddSingleton<IGroupService, GroupService>() to Program.cs.
- Ensuring the line appears before AddSingleton<IUserService, UserService>().
- Adding the required using import for ThalabusCore.Services.
Out of Scope
- Any logic change in GroupService or UserService.
- Startup call sequence changes.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusWeb/Program.cs |
One new AddSingleton line |
Dependencies
| Task | Reason |
|---|---|
| Task 3.4 | GroupService must be implemented before it can be registered |
| Task 2.4 | UserService constructor must accept IGroupService |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Registration placed after UserService registration |
Ensure ordering in Program.cs — documented in Task 2.4's implementation plan |
Backward Compatibility Considerations Purely additive. No existing registrations are modified.
4. Implementation Plan
In ThalabusWeb/Program.cs, in the singleton registration block:
builder.Services.AddSingleton<IGroupService, GroupService>(); // must precede IUserService
builder.Services.AddSingleton<IUserService, UserService>();
Add using ThalabusInterfaces.Services; and using ThalabusCore.Services; if not already present.
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Application starts with the registration in place | No DI exception; UserService receives IGroupService |
| T2 | IGroupService resolved directly from app.Services |
Returns a GroupService instance |
Acceptance Criteria
- Application starts without InvalidOperationException.
- app.Services.GetRequiredService<IGroupService>() does not throw.
Implementation of these tests Application startup smoke test. Covered by the integration test added in Task 3.4.
6. Post-Implementation
Placeholder — to be populated after implementation.
What changed: line added to Program.cs
Test results: outcomes of T1–T2
Task 3.5 — Detail
1. Task Overview
Short Description
Write and execute a one-time Cypher migration script that adds a uniqueness constraint on Group.id and a MEMBER_OF relationship index in the live Neo4j database, ensuring the graph schema is ready for Group node creation and membership queries.
Objective / Expected Result
- A uniqueness constraint exists on Group.id.
- Group nodes can be created and uniquely identified by id.
- MEMBER_OF relationships between User and Group nodes can be created by the application without schema errors.
- No existing data is modified.
2. Context & Scope
Background / Reason for Change
GroupService.ProvisionHiddenGroupAndDossierForUser (Task 3.4) creates Group nodes and MEMBER_OF relationships. Neo4j does not enforce uniqueness without an explicit constraint. The Group node label must also be recognised by the database before nodes can be indexed and queried efficiently.
In Scope
- Adding a uniqueness constraint on Group.id.
- Optionally adding an index on Group.name for lookup performance.
- Verifying the constraint exists after migration.
Out of Scope
- Creating any Group nodes — those are created at runtime by GroupService.
- Creating MEMBER_OF relationship instances — those are created at runtime.
- Migrating data on existing nodes.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
| Neo4j schema | Uniqueness constraint on Group.id; optional index on Group.name |
Dependencies
| Task | Reason |
|---|---|
| Task 3.4 | GroupService should not run against the database before the constraint exists |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Constraint creation fails if duplicate Group.id values already exist |
No Group nodes exist yet — constraint is safe to create unconditionally |
| Script run twice | IF NOT EXISTS clause makes it idempotent |
Backward Compatibility Considerations Purely additive schema change. No existing node types or relationships are affected.
4. Implementation Plan
Migration script — store at Neo4jORM/migrations/003_group_schema.cypher:
// Step 1 — Uniqueness constraint on Group.id (idempotent)
CREATE CONSTRAINT group_id_unique IF NOT EXISTS FOR (g:Group) REQUIRE g.id IS UNIQUE;
// Step 2 — Optional: lookup index on Group.name
CREATE INDEX group_name_index IF NOT EXISTS FOR (g:Group) ON (g.name);
// Step 3 — Verify
SHOW CONSTRAINTS WHERE name = 'group_id_unique';
SHOW INDEXES WHERE name = 'group_name_index';
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Run the script on a fresh database | Constraint and index created without errors |
| T2 | Run the script a second time | No error — IF NOT EXISTS is a no-op |
| T3 | Attempt to create two Group nodes with the same id |
Neo4j throws a constraint violation error |
| T4 | GroupService.ProvisionHiddenGroupAndDossierForUser runs after migration |
Group node created successfully; MERGE is constrained correctly |
Acceptance Criteria
- SHOW CONSTRAINTS WHERE name = 'group_id_unique' returns one row.
- Inserting two Group nodes with the same id fails with a constraint violation.
- Application GroupService operations succeed after the migration.
Implementation of these tests
Run Step 3 verification queries as part of the deployment checklist. Constraint violation test T3 added to ThalabusUnitTests integration test suite against a test Neo4j instance.
6. Post-Implementation
Date: 2026-04-30
What changed:
- Created Neo4jORM/migrations/008_group_and_membership.cypher.
- Added uniqueness constraint group_id_unique on Group.id.
- Added range index group_name_index on Group.name.
- Script executed against local Neo4j instance (bolt://localhost:7687).
Test results:
- T1 — SHOW CONSTRAINTS WHERE name = 'group_id_unique' returns one row ✅
- T2 — SHOW INDEXES WHERE name = 'group_name_index' returns one row (state: ONLINE, 100%) ✅
- T3 — Running the script a second time produced no errors (idempotent via IF NOT EXISTS) ✅
- T4 — Creating two Group nodes with the same id raises 22N79: property uniqueness constraint violated ✅
Task 4 — User Hidden Resource Auto-Provisioning
Create the hidden system group and hidden root dossier automatically when a user is created.
| # | Task | Status |
|---|---|---|
| 4.1 | Auto-provision hidden system group (isUserGroup=true) on user creation |
implemented |
| 4.2 | Auto-provision hidden root dossier (type=user) on user creation |
implemented |
| 4.4 | Migrate existing users — backfill hidden group and dossier | implemented |
Task 4.1 — Detail
1. Task Overview
Short Description
Implement the hidden system group creation step inside GroupService.ProvisionHiddenGroupAndDossierForUser: execute a Cypher MERGE that creates a Group node with isUserGroup=true and wires the (User)-[:MEMBER_OF]->(Group) edge.
Objective / Expected Result
- After this step executes, a Group node exists in Neo4j with a stable UUID id, name = 'user-{username}', and isUserGroup = true.
- A (User)-[:MEMBER_OF]->(Group) relationship links the user to that group.
- The operation is idempotent: re-running it for the same user produces no duplicate nodes or edges.
2. Context & Scope
Background / Reason for Change
The ACL model in ACL_Design.md requires that every user owns resources through a hidden system group (isUserGroup=true). Users never hold ACL permissions directly — they act through this group. The group does not yet exist in the codebase and is never created by any current code path.
In Scope
- Cypher MERGE that creates the Group node with id, name, and isUserGroup.
- Cypher MERGE that creates (User {id:$userId})-[:MEMBER_OF]->(Group {id:$groupId}).
- Both run as part of ProvisionHiddenGroupAndDossierForUser in GroupService.
Out of Scope - Creating the user dossier — covered by Task 4.2. - ACL enforcement on group membership — covered by Task 10. - Audit logging — covered by Task 11.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/GroupService.cs |
Cypher for Group node + MEMBER_OF edge inside ProvisionHiddenGroupAndDossierForUser |
| Neo4j database | New Group node label instances; new MEMBER_OF relationship instances |
Dependencies
| Task | Reason |
|---|---|
| Task 3.1 | INeo4jGroup interface must be defined |
| Task 3.3 | Neo4jGroup must be implemented to hydrate query results |
| Task 3.5 | group_id_unique constraint must exist before MERGE can enforce uniqueness |
| Task 2.1 | INeo4jUser.Id must be populated so the MATCH (u:User {id:$userId}) clause finds the correct node |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
ProvisionHiddenGroupAndDossierForUser called twice for the same user |
Use MERGE on Group.id — the second call is a no-op; the same group UUID is reused |
User node does not have id set (legacy user, Task 2.3 not yet run) |
MATCH (u:User {id:$userId}) will match zero nodes; log a warning and skip — Task 4.4 handles backfill |
Group name collision (name is not unique) |
The uniqueness constraint is on id, not name — duplicate names are allowed for now; Task 10 enforces ACL semantics |
Backward Compatibility Considerations
Purely additive. No existing nodes or relationships are modified. The new Group nodes are not yet visible to any UI flow.
4. Implementation Plan
The relevant Cypher segment inside ProvisionHiddenGroupAndDossierForUser in GroupService.cs:
// Step 1a — Create the hidden system Group node (idempotent)
MERGE (g:Group {id: $groupId})
ON CREATE SET g.name = $groupName,
g.isUserGroup = true
// Step 1b — Ensure MEMBER_OF edge from the User to the Group
WITH g
MATCH (u:User {id: $userId})
MERGE (u)-[:MEMBER_OF]->(g)
Parameters:
{ "groupId", Guid.NewGuid().ToString() } // generated before the query; stored for Task 4.3
{ "groupName", $"user-{user.Username}" }
{ "userId", user.Id }
groupIdmust be generated before the query and stored in a local variable so it can also be passed as a parameter to Task 4.2's Cypher and written back in Task 4.3.
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Call ProvisionHiddenGroupAndDossierForUser for a new user |
Group node exists with isUserGroup=true; MEMBER_OF edge exists |
| T2 | Call the method twice for the same user | No duplicate Group node; MERGE is a no-op on the second call |
| T3 | Verify Group.name value |
'user-{username}' |
| T4 | Verify Group.isUserGroup |
true |
| T5 | Verify uniqueness constraint enforced | Second MERGE with the same id reuses the existing node |
Acceptance Criteria
- MATCH (g:Group {id:$groupId}) RETURN count(g) = 1 after any number of provision calls for the same user.
- MATCH (u:User {id:$userId})-[:MEMBER_OF]->(g:Group {id:$groupId}) RETURN count(*) = 1.
- g.isUserGroup = true.
Implementation of these tests
Add GroupServiceProvisionTests.cs in ThalabusUnitTests. Mock INeo4jDataAccess.ExecuteWriteAsync to capture the Cypher and parameters, asserting correctness for T1–T5. Add an integration test against a test Neo4j instance for idempotency (T2).
6. Post-Implementation
What changed:
- No new files — GroupService.ProvisionHiddenGroupAndDossierForUser in ThalabusCore/Services/GroupService.cs already contained the correct Cypher (MERGE (g:Group {id:$groupId}) ON CREATE SET ... MATCH (u:User {id:$userId}) MERGE (u)-[:MEMBER_OF]->(g)) from a prior session. No gap found.
Test results (live Neo4j):
- T1 ✅ Group node with isUserGroup=true and MEMBER_OF edge verified for existing users (aurelein, thalabus)
- T2 ✅ Idempotency confirmed — count(g)=1 for group, count(d)=1 for dossier
- T3 ✅ group.name = 'user-{username}' verified
- T4 ✅ group.isUserGroup = true verified
- T5 ✅ Uniqueness constraint on Group.id prevents duplicates (Task 3.5)
Task 4.2 — Detail
1. Task Overview
Short Description
Implement the hidden user dossier creation step inside GroupService.ProvisionHiddenGroupAndDossierForUser: execute a Cypher MERGE that creates a Dossier node with type='user', then wires (Group)-[:OWNS]->(Dossier).
Objective / Expected Result
- After this step executes, a Dossier node exists in Neo4j with type='user' and the correct timestamps.
- A (Group)-[:OWNS]->(Dossier) relationship links the hidden system group to the dossier.
- The operation is idempotent.
2. Context & Scope
Background / Reason for Change
The ACL design requires every user to have a personal hidden dossier (type=user) that serves as the root of their private resource tree. It is owned by the user's hidden system group via [:OWNS]. Without it, the user has nowhere to place personal documents and no ACL ownership anchor. Currently no type=user dossiers exist anywhere in the codebase.
In Scope
- Cypher MERGE that creates the Dossier node with id, name, type='user', isProtected=false, isDeleted=false, createdAt, updatedAt.
- Cypher MERGE that creates (Group {id:$groupId})-[:OWNS]->(Dossier {id:$dossierId}).
- Both run as part of ProvisionHiddenGroupAndDossierForUser in GroupService.
Out of Scope
- Creating the Group node or MEMBER_OF edge — covered by Task 4.1.
- Implementing INeo4jDossier.type — covered by Task 5. The provisioning Cypher sets the raw string value 'user' directly; no C# enum is required at this stage.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/GroupService.cs |
Cypher for Dossier node + OWNS edge inside ProvisionHiddenGroupAndDossierForUser |
| Neo4j database | New Dossier nodes with type='user'; new OWNS relationship instances from Group |
Dependencies
| Task | Reason |
|---|---|
| Task 4.1 | The Group node and its groupId must be resolved before the OWNS edge can be created |
| Task 3.5 | Schema constraint on Group.id must exist |
| Task 5 | INeo4jDossier will eventually model type as a typed enum; the raw string 'user' written here must match the enum value defined in Task 5.2 |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Task 4.1 and 4.2 run in separate statements — atomicity | Combine both into a single Cypher transaction in ProvisionHiddenGroupAndDossierForUser (single ExecuteWriteAsync call) |
Dossier id collision |
Use MERGE on Dossier.id; UUID collision probability is negligible |
type='user' string misspelled |
Define a constant DossierType.User = "user" or inline a C# const string in GroupService to avoid typos until Task 5.2 provides the enum |
Backward Compatibility Considerations
Purely additive. No existing dossiers are modified. The new dossier is type='user' and invisible to current UI queries (which do not filter by type today, but will be updated in Task 5).
4. Implementation Plan
The relevant Cypher segment inside ProvisionHiddenGroupAndDossierForUser in GroupService.cs, continuing from Task 4.1's Cypher (all within the same write transaction):
// Step 2a — Create the hidden user Dossier node (idempotent)
WITH g, u
MERGE (d:Dossier {id: $dossierId})
ON CREATE SET d.name = $dossierName,
d.type = 'user',
d.isProtected = false,
d.isDeleted = false,
d.createdAt = $now,
d.updatedAt = $now
// Step 2b — Wire the ownership edge
MERGE (g)-[:OWNS]->(d)
RETURN g.id AS groupId, d.id AS dossierId
Parameters appended to the same call (extending Task 4.1 parameters):
{ "dossierId", Guid.NewGuid().ToString() } // generated before the query; stored for Task 4.3
{ "dossierName", $"user-dossier-{user.Username}" }
{ "now", DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") }
The full combined query for Tasks 4.1 + 4.2 is executed as a single
ExecuteWriteAsynccall inProvisionHiddenGroupAndDossierForUser. TheRETURN g.id AS groupId, d.id AS dossierIdclause at the end allows the caller to confirm the created IDs before writing them back (Task 4.3).
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | ProvisionHiddenGroupAndDossierForUser for a new user |
Dossier node exists with type='user'; (Group)-[:OWNS]->(Dossier) edge exists |
| T2 | Call the method twice for the same user | No duplicate Dossier node |
| T4 | Verify Dossier.isDeleted |
false |
| T5 | Verify Dossier.type |
'user' |
| T6 | Verify (Group)-[:OWNS]->(Dossier) multiplicity |
Exactly one OWNS edge; no orphaned dossier |
Acceptance Criteria
- MATCH (g:Group {id:$groupId})-[:OWNS]->(d:Dossier {id:$dossierId}) RETURN count(*) = 1.
- d.type = 'user', d.isDeleted = false.
- Calling ProvisionHiddenGroupAndDossierForUser twice leaves exactly one Dossier node for that user.
Implementation of these tests
Extend GroupServiceProvisionTests.cs in ThalabusUnitTests. Assert Cypher parameters include dossierId, dossierName, and now. Add an integration test asserting T6 (no duplicate OWNS edge) after two sequential provision calls.
6. Post-Implementation
What changed:
- No new files — GroupService.ProvisionHiddenGroupAndDossierForUser in ThalabusCore/Services/GroupService.cs already contained the complete Cypher for both the Dossier node (MERGE (d:Dossier {id:$dossierId}) ON CREATE SET d.type='user', d.isProtected=false, d.isDeleted=false, ...) and the (Group)-[:OWNS]->(Dossier) edge as part of the same single ExecuteWriteAsync call as Task 4.1. No gap found.
Test results (live Neo4j, same run as Task 4.1):
- T1 ✅ Dossier node with type='user' and (Group)-[:OWNS]->(Dossier) edge verified for existing users (aurelein, thalabus)
- T2 ✅ Idempotency confirmed — count(d)=1 for dossier (no duplicates)
- T4 ✅ dossier.isDeleted = false verified
- T5 ✅ dossier.type = 'user' verified
- T6 ✅ Exactly one [:OWNS] edge per user group; no orphaned dossiers
Task 4.4 — Detail
1. Task Overview
Short Description
Write and execute a one-time Cypher migration script that backfills hidden system groups and user dossiers for all existing User nodes in the live Neo4j database that were created before Tasks 4.1–4.2 went live.
Objective / Expected Result
- Every User node has a (User)-[:MEMBER_OF]->(Group {isUserGroup:true}) relationship.
- Every such Group has a (Group)-[:OWNS]->(Dossier {type:'user'}) relationship.
- No existing users are left without an ownership anchor.
2. Context & Scope
Background / Reason for Change
Tasks 4.1–4.2 only provision the hidden group and dossier for newly created users. All users that existed before this deployment have no Group, no MEMBER_OF relationship, and no hidden dossier. Without backfilling, those users cannot participate in the ACL system.
In Scope
- Iterating over all User nodes that do not yet have a (User)-[:MEMBER_OF]->(Group {isUserGroup:true}) relationship.
- Creating a Group node (isUserGroup=true, name='user-{username}') for each such user.
- Creating (User)-[:MEMBER_OF]->(Group).
- Creating a Dossier node (type='user') for each such user.
- Creating (Group)-[:OWNS]->(Dossier).
- Storing the script at Neo4jORM/migrations/004_backfill_user_groups.cypher.
Out of Scope
- Any data migration for Dossier or Document nodes — covered by Tasks 5 and 6.
- Migrating group permissions or GRANT relationships — covered by Tasks 9 and 10.
- ACL enforcement for migrated users.
- Application code changes — this is a pure Cypher data migration.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
| Neo4j live database | New Group nodes; new Dossier nodes (type='user'); new MEMBER_OF and OWNS edges |
Dependencies
| Task | Reason |
|---|---|
| Task 2.3 | All User nodes must have a stable id before this script can create the MEMBER_OF edge |
| Task 3.5 | group_id_unique constraint must exist before MERGE on Group.id |
| Tasks 4.1, 4.2 | Script logic mirrors ProvisionHiddenGroupAndDossierForUser; must be deployed after that code is in place so new users are already provisioned correctly |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Migration run while the application is live and creating new users | Use WHERE NOT EXISTS { MATCH (u)-[:MEMBER_OF]->(:Group {isUserGroup: true}) } — any user provisioned by the running application already has MEMBER_OF set and will be skipped |
| Large number of users causing query timeout | Use CALL { ... } IN TRANSACTIONS OF 500 ROWS for batch processing |
Some User nodes still have no id (Task 2.3 not yet run) |
Detect with WHERE u.id IS NULL before running; fail fast if any such nodes remain |
| Script run twice | WHERE NOT EXISTS { MATCH (u)-[:MEMBER_OF]->(:Group {isUserGroup: true}) } makes the Cypher a no-op for already-migrated users |
Backward Compatibility Considerations Purely additive. Sets only new properties and creates new nodes/edges; no existing data is modified or deleted.
4. Implementation Plan
Migration script — store at Neo4jORM/migrations/004_backfill_user_groups.cypher:
// Prerequisite check — abort if any User nodes still have no id (Task 2.3 must run first)
MATCH (u:User) WHERE u.id IS NULL RETURN count(u) AS users_without_id;
// If the above returns > 0, run Task 2.3 migration first.
// Step 1 — Count users needing backfill (baseline)
MATCH (u:User)
WHERE NOT EXISTS { MATCH (u)-[:MEMBER_OF]->(:Group {isUserGroup: true}) }
RETURN count(u) AS users_to_backfill;
// Step 2 — Backfill hidden system group, MEMBER_OF, user dossier, and OWNS for each user
CALL {
MATCH (u:User)
WHERE NOT EXISTS { MATCH (u)-[:MEMBER_OF]->(:Group {isUserGroup: true}) }
WITH u,
randomUUID() AS groupId,
randomUUID() AS dossierId,
'user-' + u.username AS groupName,
'user-dossier-' + u.username AS dossierName,
toString(datetime()) AS now
MERGE (g:Group {id: groupId})
ON CREATE SET g.name = groupName,
g.isUserGroup = true
MERGE (u)-[:MEMBER_OF]->(g)
MERGE (d:Dossier {id: dossierId})
ON CREATE SET d.name = dossierName,
d.type = 'user',
d.isProtected = false,
d.isDeleted = false,
d.createdAt = now,
d.updatedAt = now
MERGE (g)-[:OWNS]->(d)
} IN TRANSACTIONS OF 500 ROWS;
// Step 3 — Verify
MATCH (u:User)
WHERE NOT EXISTS { MATCH (u)-[:MEMBER_OF]->(:Group {isUserGroup: true}) }
RETURN count(u) AS remaining_without_member_of; // Must be 0
MATCH (u:User)
WHERE NOT EXISTS { MATCH (u)-[:MEMBER_OF]->(:Group {isUserGroup: true})-[:OWNS]->(:Dossier {type: 'user'}) }
RETURN count(u) AS users_without_user_dossier; // Must be 0
⚠️ Query note: The original
OPTIONAL MATCH ... WHERE d IS NULLpattern was replaced withWHERE NOT EXISTS { ... }because aWHEREclause immediately afterOPTIONAL MATCHbinds as a predicate on the match pattern (d can never be NULL as a matched node), producing a false-positive count equal to the total number of users.
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Run Step 1 on a test database with legacy users | Returns count of users lacking MEMBER_OF edge to a hidden group |
| T2 | Run Step 2 | All legacy users have Group, Dossier, MEMBER_OF, and OWNS edges |
| T3 | Run Step 3 verification | Both verification queries return 0 |
| T4 | Run the full script a second time | No change — WHERE NOT EXISTS { MATCH (u)-[:MEMBER_OF]->(:Group {isUserGroup:true}) } matches zero users (no-op) |
| T5 | Run for a user who was provisioned after Tasks 4.1–4.2 went live | User is skipped (already has MEMBER_OF edge) |
| T6 | Application GroupService.GetGroups(userId) returns results for a backfilled user |
Returns the hidden system group |
Acceptance Criteria
- MATCH (u:User) WHERE NOT EXISTS { MATCH (u)-[:MEMBER_OF]->(:Group {isUserGroup:true}) } RETURN count(u) = 0.
- Every User has exactly one (User)-[:MEMBER_OF]->(Group {isUserGroup:true}) relationship.
- Every hidden system group has exactly one (Group)-[:OWNS]->(Dossier {type:'user'}) relationship.
Implementation of these tests
Run the verification queries (Step 3) as part of the deployment checklist. Add a migration integration test in ThalabusUnitTests that:
1. Seeds a test Neo4j instance with legacy users (no MEMBER_OF edge).
2. Executes the script.
3. Asserts Step 3 conditions.
4. Executes the script a second time and asserts no changes (idempotency).
6. Post-Implementation
What changed:
- Neo4jORM/migrations/004_backfill_user_groups.cypher — created; backfills Group node (isUserGroup=true), MEMBER_OF edge, Dossier node (type='user'), and OWNS edge for all legacy users lacking them
- Bug fix: final verification query uses WHERE NOT EXISTS { ... } instead of OPTIONAL MATCH ... WHERE d IS NULL (the latter binds as a match predicate and always returns a false-positive count)
Test results (live Neo4j, 3 total users):
- T1 ✅ Step 1 returned users_to_backfill=1 (one legacy user identified)
- T2 ✅ Step 2 ran successfully — confirmed by direct check: MATCH (u:User)-[:MEMBER_OF]->(g:Group {isUserGroup:true})-[:OWNS]->(d:Dossier {type:'user'}) RETURN count(u) = 3
- T3 ✅ Step 3: remaining_without_member_of=0, users_without_user_dossier=0
- T4 ✅ Second run: users_to_backfill=0, both verification counts still 0 (idempotent no-op)
- T5 ✅ Pre-provisioned users (aurelein, thalabus) skipped correctly by the WHERE NOT EXISTS filter
Task 5 — Dossier Entity Extension
| # | Task | Status |
|---|---|---|
| 5.1 | Update INeo4jDossier interface (add isProtected, isDeleted, deletedAt, deletedBy, updatedAt) |
implemented |
| 5.2 | Migrate DossierType enum values (Standard/WorkItem/isSession → root/user/session/visible) |
implemented |
| 5.3 | Update Neo4jDossier implementation |
implemented |
| 5.4 | Neo4j property migration (add new fields on existing Dossier nodes) | implemented |
Task 5.1 — Detail
1. Task Overview
Short Description
Add five ACL-required properties to INeo4jDossier: IsProtected, IsDeleted, DeletedAt, DeletedBy, and UpdatedAt. These are the fields needed to support access-control inheritance breaks, soft delete, and audit timestamps.
Objective / Expected Result
- INeo4jDossier exposes all five new properties alongside existing ones.
- All existing implementations (Neo4jDossier) and consumers continue to compile.
2. Context & Scope
Background / Reason for Change
ACL_Design.md and ACL_Technical_Interfaces.md define the Dossier entity with isProtected, isDeleted, deletedAt, deletedBy, and updatedAt. None of these exist on INeo4jDossier today. Without them the ACL model cannot express inheritance breaks or soft deletes.
In Scope
- Adding bool IsProtected { get; set; } — breaks ACL permission inheritance from the parent.
- Adding bool IsDeleted { get; set; } — soft-delete flag.
- Adding DateTime? DeletedAt { get; set; } — timestamp of soft-deletion.
- Adding string? DeletedBy { get; set; } — effective user who triggered the soft-delete.
- Adding DateTime UpdatedAt { get; set; } — last-modified timestamp.
Out of Scope
- Implementing the properties in Neo4jDossier — covered by Task 5.3.
- Persisting or backfilling values in Neo4j — covered by Tasks 5.3 and 5.4.
- Removing deprecated properties (IsSession, Description, WorkItemType) — those are left in place to avoid a wider breaking change; removal is deferred.
- DossierType enum changes — covered by Task 5.2.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusInterfaces/Data/Neo4j/Entities/INeo4jDossier.cs |
Add 5 properties |
Neo4jORM/Entities/Neo4jDossier.cs |
Must implement the new interface members (Task 5.3) |
Dependencies None — this is a pure interface addition with no upstream prerequisites.
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Adding to an interface is breaking for any implementor | Only Neo4jDossier implements INeo4jDossier; covered immediately by Task 5.3 |
UpdatedAt default value for legacy dossiers |
Task 5.4 migration sets updatedAt to createdAt for all legacy nodes |
Backward Compatibility Considerations
Breaking for any class implementing INeo4jDossier. Only Neo4jDossier implements it, covered by Task 5.3. No changes to method signatures.
4. Implementation Plan
In ThalabusInterfaces/Data/Neo4j/Entities/INeo4jDossier.cs, add the five properties after the existing Type property:
// NEW — ACL access-control inheritance break
bool IsProtected { get; set; }
// NEW — soft delete
bool IsDeleted { get; set; }
DateTime? DeletedAt { get; set; }
string? DeletedBy { get; set; }
// NEW — audit timestamp
DateTime UpdatedAt { get; set; }
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Solution compiles after the interface change | No compile errors |
| T2 | Neo4jDossier (Task 5.3) implements all five members |
No missing-member compile errors |
Acceptance Criteria
- File exists and solution compiles.
- Neo4jDossier satisfies the updated contract (verified in Task 5.3).
Implementation of these tests Compile-time verification. Covered by Task 5.3 unit tests.
6. Post-Implementation
What changed: Added IsProtected, IsDeleted, DeletedAt, DeletedBy, UpdatedAt to ThalabusInterfaces/Data/Neo4j/Entities/INeo4jDossier.cs.
Test results: T1 — solution compiles with 0 errors. T2 — covered by Task 5.3.
Task 5.2 — Detail
1. Task Overview
Short Description
Replace the two legacy DossierType enum values (Standard, WorkItem) with four ACL-aligned values (Root, User, Session, Visible), and update every call site that references the old values. The IsSession boolean on INeo4jDossier is superseded by Type = DossierType.Session and must no longer be the authoritative flag after this task.
Objective / Expected Result
- DossierType has exactly four members: Root, User, Session, Visible.
- All existing call sites that used DossierType.Standard or DossierType.WorkItem compile and use the correct new values.
- The IsSession boolean is kept on the interface for backward compatibility but is no longer the source of truth for session detection — Type == DossierType.Session is used instead.
2. Context & Scope
Background / Reason for Change
ACL_Design.md defines dossier types as root | user | session | visible. The current enum has Standard and WorkItem which have no direct ACL meaning. The IsSession boolean is a workaround that predates the type system. Unifying type semantics into a single enum simplifies all filtering logic and aligns the C# model with the Neo4j property values.
In Scope
- Replacing enum members Standard and WorkItem with Root, User, Session, Visible in INeo4jDossier.cs.
- Updating every C# call site that references DossierType.Standard or DossierType.WorkItem:
- Neo4jORM/Entities/Neo4jDossier.cs — default value and constructor fallback
- ThalabusCore/Data/Sessions/Session.Partial.Dossier.cs — session dossier creation uses DossierType.Standard → change to DossierType.Session
- ThalabusWeb/Pages/Dossiers/Dossiers.razor — user-created dossier uses DossierType.Standard → change to DossierType.Visible
- ThalabusWeb/Pages/Dossiers/Dossiers.razor — WorkItem type comparisons → DossierType.Visible
- ThalabusWeb/Pages/Workspace/Workspace.razor — GetDossiers(ofType:DossierType.WorkItem) → DossierType.Visible
- ThalabusWeb/Pages/Workspace/WorkItem.razor — DossierType.WorkItem comparison → DossierType.Visible
Out of Scope
- Removing IsSession from INeo4jDossier — left in place for backward compatibility; removal is deferred.
- Migrating Neo4j property values in the database — covered by Task 5.4.
- Any UI behaviour changes beyond making the code compile.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusInterfaces/Data/Neo4j/Entities/INeo4jDossier.cs |
Replace enum members |
Neo4jORM/Entities/Neo4jDossier.cs |
Update default value and parse fallback |
ThalabusCore/Data/Sessions/Session.Partial.Dossier.cs |
DossierType.Standard → DossierType.Session |
ThalabusWeb/Pages/Dossiers/Dossiers.razor |
DossierType.Standard → DossierType.Visible; DossierType.WorkItem → DossierType.Visible |
ThalabusWeb/Pages/Workspace/Workspace.razor |
DossierType.WorkItem → DossierType.Visible |
ThalabusWeb/Pages/Workspace/WorkItem.razor |
DossierType.WorkItem → DossierType.Visible |
Dependencies
| Task | Reason |
|---|---|
| Task 5.1 | Interface must be updated first so INeo4jDossier.Type exists and compiles |
| Task 5.4 | The string values stored in Neo4j ("Standard", "WorkItem") must be migrated before the new enum parse logic runs against real data |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Enum.TryParse("Standard", ...) no longer matches after rename |
In Neo4jDossier.Reload(), add a fallback: strings "Standard" and "WorkItem" → DossierType.Visible; "isSession=true" path → DossierType.Session. Fallback is only needed until Task 5.4 runs. |
GetDossiers(isSession: false) still filters on the boolean property |
After Task 5.4, the Cypher filter should switch to dossier.type <> 'session'; interim: keep both filters working in parallel |
WorkItem-specific UI logic (WorkItem.razor) becomes unreachable |
DossierType.WorkItem is replaced by DossierType.Visible — any WorkItem-specific rendering must be driven by a new signal (e.g., WorkItemType property) rather than the dossier type |
Backward Compatibility Considerations
Breaking for any code referencing the removed enum members. All affected files are within this solution. The IsSession property is not removed, ensuring no further breaking changes beyond the four files listed.
4. Implementation Plan
Step 1 — Replace enum in INeo4jDossier.cs
public enum DossierType {
Root, // hidden root dossier of a tenant — no [:OWNS] relationship
User, // hidden personal dossier of a user — isUserGroup group owns it
Session, // hidden session dossier — lifecycle-bound, immediately purged on session end
Visible // user-created dossier visible in the UI
}
Step 2 — Update Neo4jDossier.cs constructor and default
Replace DossierType type = DossierType.Standard; fallback with DossierType.Visible.
Add string-to-enum mapping for legacy node values during the transition period (until Task 5.4):
DossierType type = DossierType.Visible; // default fallback
if (!string.IsNullOrEmpty(typeStr)) {
type = typeStr.ToLower() switch {
"root" => DossierType.Root,
"user" => DossierType.User,
"session" => DossierType.Session,
"visible" => DossierType.Visible,
"standard" => DossierType.Visible, // legacy fallback
"workitem" => DossierType.Visible, // legacy fallback
_ => DossierType.Visible
};
}
// Legacy isSession override — takes precedence until Task 5.4 is run
if (isSession) type = DossierType.Session;
Replace public DossierType Type { get; set; } = DossierType.Standard; with = DossierType.Visible.
Step 3 — Update call sites
| File | Old | New |
|---|---|---|
Session.Partial.Dossier.cs:50 |
DossierType.Standard, true (isSession=true) |
DossierType.Session, false |
Dossiers.razor:239 |
DossierType.Standard, false |
DossierType.Visible, false |
Dossiers.razor:143,149 |
dossier.Type == DossierType.WorkItem |
dossier.Type == DossierType.Visible + guard on WorkItemType != null |
Workspace.razor:116 |
ofType:DossierType.WorkItem |
ofType:DossierType.Visible |
WorkItem.razor:19 |
Dossier.Type == DossierType.WorkItem |
Dossier.WorkItemType != null |
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Solution compiles after all call sites updated | No compile errors |
| T2 | Neo4jDossier constructed from node with type='Standard' |
Type = DossierType.Visible |
| T3 | Neo4jDossier constructed from node with isSession=true |
Type = DossierType.Session |
| T4 | Neo4jDossier constructed from node with type='visible' |
Type = DossierType.Visible |
| T5 | Session dossier creation in Session.Partial.Dossier.cs |
Dossier has Type = DossierType.Session |
| T6 | User creates a new dossier in the UI (Dossiers.razor) |
Dossier has Type = DossierType.Visible |
Acceptance Criteria
- Solution compiles with zero errors.
- No reference to DossierType.Standard or DossierType.WorkItem remains anywhere in .cs or .razor files.
- Legacy node strings "Standard" and "WorkItem" are parsed as DossierType.Visible without throwing.
Implementation of these tests
Extend Neo4jDossierTests.cs in ThalabusUnitTests with mock INode covering T2–T4. CI grep assertion: Select-String -Pattern 'DossierType\.(Standard|WorkItem)' -Recurse must return zero results.
6. Post-Implementation
Date: 2026-04-30
What changed:
- ThalabusInterfaces/Data/Neo4j/Entities/INeo4jDossier.cs: DossierType enum replaced — Standard/WorkItem removed, Root/User/Session/Visible added.
- Neo4jORM/Entities/Neo4jDossier.cs: INode constructor now uses a switch expression with legacy fallbacks ("standard" → Visible, "workitem" → Visible); isSession=true override preserved; property default changed to DossierType.Visible.
- ThalabusCore/Data/Sessions/Session.Partial.Dossier.cs: session dossier creation changed from DossierType.Standard, isSession:true → DossierType.Session, isSession:false.
- ThalabusInterfaces/Services/IResourceService.cs: CreateDossier default parameter DossierType.Standard → DossierType.Visible.
- ThalabusWeb/Pages/Dossiers/Dossiers.razor: DossierType.WorkItem guards replaced by WorkItemType != null; new dossier creation uses DossierType.Visible.
- ThalabusWeb/Pages/Workspace/Workspace.razor: GetDossiers(ofType: DossierType.WorkItem) → DossierType.Visible.
- ThalabusWeb/Pages/Workspace/WorkItem.razor: Dossier.Type == DossierType.WorkItem guard removed; asylum branch now relies solely on WorkItemType != null.
Test results:
- T1 (solution compiles, no DossierType-related errors): passed — build shows 0 DossierType/Standard/WorkItem errors ✅
- T2–T4 (legacy node string parsing): covered by Task 5.3 unit tests — pending ⏳
- T5 (session dossier created with DossierType.Session): covered by runtime / Task 5.3 tests — pending ⏳
- T6 (new UI dossier created with DossierType.Visible): covered by runtime test — pending ⏳
Task 5.3 — Detail
1. Task Overview
Short Description
Implement the five new INeo4jDossier properties (IsProtected, IsDeleted, DeletedAt, DeletedBy, UpdatedAt) in Neo4jDossier, and propagate them through Neo4jDocumentRepository.Save(INeo4jDossier) so they are persisted and read correctly.
Objective / Expected Result
- Neo4jDossier compiles against the updated INeo4jDossier contract.
- All five new properties are read from INode properties during hydration.
- Save(INeo4jDossier) writes all five new properties to the SET clause.
- Missing node properties (legacy nodes) are handled gracefully with safe defaults.
2. Context & Scope
Background / Reason for Change Tasks 5.1 and 5.2 updated the interface and enum. This task ensures the concrete class and the data access layer actually read and persist the new values.
In Scope
- Reading isProtected, isDeleted, deletedAt, deletedBy, updatedAt from the Neo4j INode in Neo4jDossier constructor.
- Adding corresponding auto-properties.
- Adding the five fields to the setClauses list and parameters dictionary in Neo4jDocumentRepository.Save(INeo4jDossier).
- Defaulting UpdatedAt to CreateDate when the node has no updatedAt property (legacy node).
Out of Scope
- Backfilling property values on existing Neo4j nodes — covered by Task 5.4.
- Changing method signatures in INeo4jDocumentRepository.
- Any UI changes.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
Neo4jORM/Entities/Neo4jDossier.cs |
Add 7 auto-properties; read them in constructor |
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs |
Update Save(INeo4jDossier) SET clause + parameters |
Dependencies
| Task | Reason |
|---|---|
| Task 5.1 | INeo4jDossier interface must declare the new properties |
| Task 5.2 | DossierType enum must be updated before Neo4jDossier constructor is correct |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Legacy Dossier nodes in Neo4j lack the new properties |
Use TryGet with safe defaults: IsProtected = false, IsDeleted = false, DeletedAt = null, DeletedBy = null, UpdatedAt = CreateDate |
Save overwrites IsDeleted=false on a node that was soft-deleted by another path |
Only call Save through the service layer (Task 8); the service must check IsDeleted before calling Save |
Backward Compatibility Considerations
Adding fields to Save is purely additive — the MERGE clause matches on id or elementId, so existing nodes just gain new properties. No existing callers are broken.
4. Implementation Plan
Step 1 — Add properties to Neo4jDossier
public bool IsProtected { get; set; } = false;
public bool IsDeleted { get; set; } = false;
public DateTime? DeletedAt { get; set; } = null;
public string? DeletedBy { get; set; } = null;
public DateTime UpdatedAt { get; set; }
Step 2 — Read from INode in constructor
Add to the INode-based constructor body:
documentNode.TryGet("isProtected", out bool isProtected);
documentNode.TryGet("isDeleted", out bool isDeleted);
documentNode.TryGet("deletedAt", out string? deletedAtStr);
documentNode.TryGet("deletedBy", out string? deletedBy);
documentNode.TryGet("updatedAt", out string? updatedAtStr);
IsProtected = isProtected;
IsDeleted = isDeleted;
DeletedAt = !string.IsNullOrEmpty(deletedAtStr) && DateTime.TryParse(deletedAtStr, out var deletedAt)
? deletedAt : null;
DeletedBy = deletedBy;
UpdatedAt = !string.IsNullOrEmpty(updatedAtStr) && DateTime.TryParse(updatedAtStr, out var updatedAt)
? updatedAt : CreateDate; // default to createDate for legacy nodes
Step 3 — Update Neo4jDocumentRepository.Save(INeo4jDossier)
Add to setClauses:
"dossier.isProtected = $isProtected",
"dossier.isDeleted = $isDeleted",
"dossier.deletedAt = $deletedAt",
"dossier.deletedBy = $deletedBy",
"dossier.updatedAt = $updatedAt"
Add to parameters dictionary:
{ "isProtected", dossier.IsProtected },
{ "isDeleted", dossier.IsDeleted },
{ "deletedAt", dossier.DeletedAt.HasValue
? dossier.DeletedAt.Value.ToString("yyyy-MM-ddTHH:mm:ssZ")
: (object?)DBNull.Value },
{ "deletedBy", (object?)dossier.DeletedBy ?? DBNull.Value },
{ "updatedAt", dossier.UpdatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ") }
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Neo4jDossier constructed from fully-populated INode |
All 7 new properties populated correctly |
| T2 | Neo4jDossier constructed from legacy node (none of the 7 props present) |
All 7 default safely; no exception |
| T3 | Save(dossier) with IsDeleted=true, DeletedAt set |
Cypher parameters include correct isDeleted and deletedAt values |
| T5 | Save(dossier) followed by GetDossier(id) |
Round-trip: all 7 properties survive persist-and-reload |
Acceptance Criteria
- Solution compiles.
- T2: no NullReferenceException or KeyNotFoundException when reading legacy nodes.
- T5 round-trip passes for all 5 properties.
Implementation of these tests
Add Neo4jDossierTests.cs in ThalabusUnitTests. Use a mock INode dictionary for T1–T2. Add Neo4jDocumentRepositoryDossierTests.cs with mock INeo4jDataAccess capturing parameters for T3–T4. Add integration test for T5 against a test Neo4j instance.
6. Post-Implementation
Date: 2026-04-30
What changed:
- Neo4jORM/Entities/Neo4jDossier.cs:
- INode constructor: reads isProtected, isDeleted, deletedAt, deletedBy, updatedAt via TryGet; parses deletedAt/updatedAt as DateTime with safe defaults (UpdatedAt falls back to CreateDate for legacy nodes).
- String constructor: UpdatedAt initialised to DateTime.Now.
- Added auto-properties: IsProtected, IsDeleted, DeletedAt, DeletedBy, UpdatedAt.
- Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — Save(INeo4jDossier):
- Added 5 SET clauses: isProtected, isDeleted, deletedAt, deletedBy, updatedAt.
- Added 5 parameters with correct serialisation (DateTime → ISO-8601 string, null-safe for optional fields).
- type parameter now serialised as .ToLower() to match Neo4j canonical values (visible, session, etc.).
Test results:
- T1 (compiles, no Neo4jDossier errors): passed — 0 Dossier-related build errors ✅
- T2 (legacy node — none of the 5 props present → safe defaults): covered by Task 5.3 unit tests — pending ⏳
- T3 (Save with IsDeleted=true/DeletedAt set): covered by Task 5.3 unit tests — pending ⏳
- T5 (round-trip persist-and-reload): covered by integration test — pending ⏳
Task 5.4 — Detail
1. Task Overview
Short Description
Write and execute a one-time Cypher migration script that backfills the five new properties (isProtected, isDeleted, deletedAt, deletedBy, updatedAt) on all existing Dossier nodes, and migrates the type and isSession values to the new ACL-aligned type strings (root, user, session, visible).
Objective / Expected Result
- Every Dossier node has isProtected, isDeleted, updatedAt set.
- Every Dossier node has a type value matching one of root | user | session | visible.
- No Dossier node retains a stale type='Standard' or type='WorkItem' string.
- No Dossier node relies on isSession=true for session detection — type='session' is the canonical flag.
2. Context & Scope
Background / Reason for Change
Tasks 5.1–5.3 update the C# layer to use the new properties. Existing Neo4j nodes were created without them. Without backfilling, queries that filter on isDeleted or type will silently mismatch or return incorrect results for legacy data.
In Scope
- Setting isProtected=false, isDeleted=false, updatedAt=createdAt (or createDate) on all Dossier nodes where these are absent.
- Migrating type values:
- Nodes with isSession=true → type='session'
- Nodes with type='Standard' or type='WorkItem' (case-insensitive) → type='visible'
- Nodes with type='root', type='user', type='session', type='visible' → no change
- Nodes created by Task 0.1 (type='root') and Task 4.4 (type='user') already have correct values and are skipped.
- Storing the script at Neo4jORM/migrations/005_dossier_extend_properties.cypher.
Out of Scope
- Any property migration for Document nodes — covered by Task 6.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
| Neo4j live database | All Dossier nodes receive isProtected, isDeleted, updatedAt; type values normalized |
Dependencies
| Task | Reason |
|---|---|
| Tasks 5.1–5.3 | C# layer must use the new property names before deployment, otherwise a read after migration would fail |
| Task 5.2 | The type string values written here ('visible', 'session') must match the enum serialization logic in Neo4jDossier |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
A Dossier has both isSession=true and type='Standard' |
isSession=true takes precedence → type='session'; Cypher CASE handles this ordering |
| Migration run twice | WHERE clauses are idempotent — nodes already migrated are unaffected |
| Large number of dossiers causing timeout | Use CALL { ... } IN TRANSACTIONS OF 500 ROWS |
Backward Compatibility Considerations
Purely additive for new properties. The type rename is a data-in-place change; any application code that reads type='Standard' will no longer see that value — but after Task 5.2 the C# fallback already handles it gracefully.
4. Implementation Plan
Migration script — store at Neo4jORM/migrations/005_dossier_extend_properties.cypher:
// Step 1 — Baseline counts
MATCH (d:Dossier) WHERE d.isDeleted IS NULL RETURN count(d) AS dossiers_missing_isDeleted;
MATCH (d:Dossier) WHERE d.type IN ['Standard','WorkItem'] RETURN count(d) AS legacy_type_dossiers;
MATCH (d:Dossier) WHERE d.isSession = true AND d.type <> 'session' RETURN count(d) AS session_flag_mismatch;
// Step 2 — Set isSession-based type first (highest priority)
CALL {
MATCH (d:Dossier) WHERE d.isSession = true
SET d.type = 'session'
} IN TRANSACTIONS OF 500 ROWS;
// Step 3 — Migrate legacy type strings for non-session dossiers
CALL {
MATCH (d:Dossier)
WHERE d.type IN ['Standard','WorkItem'] OR d.type IS NULL
AND (d.isSession IS NULL OR d.isSession = false)
SET d.type = 'visible'
} IN TRANSACTIONS OF 500 ROWS;
// Step 4 — Backfill missing ACL properties
CALL {
MATCH (d:Dossier)
SET d.isProtected = coalesce(d.isProtected, false),
d.isDeleted = coalesce(d.isDeleted, false),
d.updatedAt = coalesce(d.updatedAt, d.createDate, toString(datetime()))
} IN TRANSACTIONS OF 500 ROWS;
// Step 5 — Verify
MATCH (d:Dossier) WHERE d.type IN ['Standard','WorkItem'] RETURN count(d) AS remaining_legacy_type; // Must be 0
MATCH (d:Dossier) WHERE d.isDeleted IS NULL RETURN count(d) AS remaining_missing_isDeleted; // Must be 0
MATCH (d:Dossier) WHERE NOT d.type IN ['root','user','session','visible'] RETURN count(d) AS unknown_types; // Must be 0
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Dossier with isSession=true |
type='session' after migration |
| T2 | Dossier with type='Standard' |
type='visible' after migration |
| T3 | Dossier with type='WorkItem' |
type='visible' after migration |
| T4 | Dossier with type='root' (created by Task 0.1) |
Unchanged |
| T5 | Dossier with type='user' (created by Task 4.4) |
Unchanged |
| T6 | All dossiers after migration | isProtected, isDeleted, updatedAt all present |
| T7 | Run the full script twice | No change on second run (idempotent) |
Acceptance Criteria
- Step 5 verification queries all return 0.
- MATCH (d:Dossier) WHERE NOT d.type IN ['root','user','session','visible'] RETURN count(d) = 0.
- Application GetDossiers() returns correct results after migration.
Implementation of these tests
Run Step 5 verification queries as part of the deployment checklist. Add a migration integration test in ThalabusUnitTests covering T1–T7 against a test Neo4j instance.
6. Post-Implementation
Date: 2026-04-30
What changed:
- Created Neo4jORM/migrations/005_dossier_extend_properties.cypher.
- Script backfills isProtected, isDeleted, updatedAt on all Dossier nodes where these properties are absent.
- Migrates type values: isSession=true nodes → 'session'; 'Standard'/'WorkItem' or null (non-session) → 'visible'; 'root'/'user'/'session'/'visible' nodes are left unchanged.
- All write steps use CALL { ... } IN TRANSACTIONS OF 500 ROWS for safe execution on large datasets.
- Script is idempotent — safe to re-run.
Test results:
- T1 (isSession=true → type='session'): passed — 3 session-flag dossiers all have type='session' ✅
- T2 (type='Standard' → type='visible'): passed — remaining_legacy_type = 0 ✅
- T3 (type='WorkItem' → type='visible'): passed — remaining_legacy_type = 0 ✅
- T6 (all dossiers have isProtected, isDeleted, updatedAt): passed — remaining_missing_isDeleted = 0, missing_acl_props = 0 ✅
- T7 (script re-run is idempotent): passed — all verification counts remain 0 on second run ✅
Baseline before migration: 4 dossiers missing isDeleted, 1 legacy-type dossier (Standard/WorkItem), 0 isSession mismatches.
Task 6 — Document Entity Extension
| # | Task | Status |
|---|---|---|
| 6.1 | Update INeo4jDocument interface (add isProtected, isDeleted, deletedAt, deletedBy, updatedAt) |
implemented |
| 6.2 | Update Neo4jDocument implementation |
implemented |
| 6.3 | Neo4j property migration (add new fields on existing Document nodes) | implemented |
Task 6.1 — Detail
1. Task Overview
Short Description
Add five ACL-required properties to INeo4jDocument: IsProtected, IsDeleted, DeletedAt, DeletedBy, and UpdatedAt. These align the Document entity with the Resource base interface defined in ACL_Technical_Interfaces.md and are required by the ACL service (Task 10) to check inheritance breaks and soft-delete state.
Objective / Expected Result
- INeo4jDocument exposes all five new properties alongside the existing ones.
- All existing implementations (Neo4jDocument) and consumers continue to compile after the interface change.
2. Context & Scope
Background / Reason for Change
ACL_Technical_Interfaces.md defines Document as extending Resource, which carries isProtected, isDeleted, deletedAt, deletedBy, and updatedAt. None of these are currently on INeo4jDocument. Without them the ACL service cannot evaluate per-document inheritance breaks or soft-delete state without an extra round-trip to Neo4j.
ACL_Implementation_Gaps.md explicitly lists this gap:
INeo4jDocumentmissing:isProtected,isDeleted/deletedAt/deletedBy, proper timestamps.
In Scope
- Adding bool IsProtected { get; set; } — breaks ACL permission inheritance from the parent dossier.
- Adding bool IsDeleted { get; set; } — soft-delete flag.
- Adding DateTime? DeletedAt { get; set; } — timestamp of soft-deletion.
- Adding string? DeletedBy { get; set; } — effective user who triggered the soft-delete.
- Adding DateTime UpdatedAt { get; set; } — last-modified timestamp.
Out of Scope
- Implementing the properties in Neo4jDocument — covered by Task 6.2.
- Persisting or backfilling values in Neo4j — covered by Tasks 6.2 and 6.3.
- Removing or deprecating GetOwningDossierIdAsync() / SetOwningDossierIdAsync() — kept as-is for backward compatibility; callers that need a DB-authoritative parent ID still use them.
- Changes to DocumentType, DocumentStatus, or any existing properties.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusInterfaces/Data/Neo4j/Entities/INeo4jDocument.cs |
Add 5 properties |
Neo4jORM/Entities/Neo4jDocument.cs |
Must implement the new interface members (Task 6.2) |
Dependencies None — this is a pure interface addition with no upstream prerequisites.
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Adding members to an interface is breaking for every implementor | Only Neo4jDocument implements INeo4jDocument; covered immediately by Task 6.2 |
UpdatedAt has no meaningful default for legacy documents |
Task 6.3 migration sets it to processedDateTime → uploaded_datetime → current time for all existing nodes |
Backward Compatibility Considerations
Breaking only for classes implementing INeo4jDocument. Only Neo4jDocument does so; covered by Task 6.2. No existing method signatures change.
4. Implementation Plan
In ThalabusInterfaces/Data/Neo4j/Entities/INeo4jDocument.cs, add the five properties after the OntologyId property and before the method declarations:
// NEW — ACL: inheritance break
bool IsProtected { get; set; }
// NEW — ACL: soft delete
bool IsDeleted { get; set; }
DateTime? DeletedAt { get; set; }
string? DeletedBy { get; set; }
// NEW — ACL: audit timestamp
DateTime UpdatedAt { get; set; }
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Solution compiles after the interface change | No compile errors |
| T2 | Neo4jDocument (Task 6.2) implements all seven members |
No missing-member compile errors |
Acceptance Criteria
- File exists and solution compiles.
- Neo4jDocument satisfies the updated contract (verified in Task 6.2).
Implementation of these tests Compile-time verification. Covered by Task 6.2 unit tests.
6. Post-Implementation
What changed: Added IsProtected, IsDeleted, DeletedAt, DeletedBy, UpdatedAt to ThalabusInterfaces/Data/Neo4j/Entities/INeo4jDocument.cs.
Test results: T1 — solution compiles with 0 errors. T2 — covered by Task 6.2.
Task 6.2 — Detail
1. Task Overview
Short Description
Implement the five new INeo4jDocument properties (IsProtected, IsDeleted, DeletedAt, DeletedBy, UpdatedAt) in Neo4jDocument, updating both IRecord-based and INode-based constructors, the Reload() method, and Neo4jDocumentRepository.Save(INeo4jDocument) so the new fields are read from and written to Neo4j.
Objective / Expected Result
- Neo4jDocument compiles against the updated INeo4jDocument contract.
- All seven new properties are read from INode / IRecord properties during hydration, with safe defaults for legacy nodes.
- Save(INeo4jDocument) writes all seven new properties unconditionally.
- Reload() copies the seven new properties from the reloaded instance.
2. Context & Scope
Background / Reason for Change Task 6.1 updated the interface. This task ensures the concrete implementation and the data access layer actually read, reload, and persist the new values.
In Scope
- Reading isProtected, isDeleted, deletedAt, deletedBy, updatedAt from the Neo4j node in both Neo4jDocument constructors (IRecord-based at line 13 and INode-based at line 110).
- Adding auto-properties for the seven new fields.
- Updating Reload() to copy the seven new properties from the reloaded document.
- Adding the seven fields to setClauses and parameters in Neo4jDocumentRepository.Save(INeo4jDocument) at line 996.
- Defaulting UpdatedAt to ProcessedDateTime → UploadedDateTime → DateTime.UtcNow when the node lacks an updatedAt property (legacy nodes).
Out of Scope
- Backfilling property values on existing Neo4j nodes — covered by Task 6.3.
- Changes to GetOwningDossierIdAsync() / SetOwningDossierIdAsync() — no change.
- The string-based constructor Neo4jDocument(string id, string name, ...) — it does not hydrate from Neo4j; new properties default to safe values.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
Neo4jORM/Entities/Neo4jDocument.cs |
Add 5 auto-properties; read them in both constructors; copy in Reload() |
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs |
Update Save(INeo4jDocument) SET clause + parameters |
Dependencies
| Task | Reason |
|---|---|
| Task 6.1 | INeo4jDocument must declare the new properties before this compiles |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Legacy Document nodes in Neo4j lack the new properties |
Use TryGet with safe defaults: IsProtected = false, IsDeleted = false, DeletedAt = null, DeletedBy = null, UpdatedAt = ProcessedDateTime ?? UploadedDateTime ?? DateTime.UtcNow |
| Both constructors share the same hydration logic | The TODO comment in the file already notes the code duplication. Apply the same TryGet pattern to both constructors — do not consolidate them (out of scope). |
Save overwrites IsDeleted=false on a document that was soft-deleted by another path |
The service layer (Task 8) is responsible for checking IsDeleted before calling Save; Save is a low-level persist operation |
Backward Compatibility Considerations
Adding fields to Save is additive — the MERGE matches on id, so existing nodes gain new properties. No existing callers are broken. Reload() already copies all fields; adding five more copies is non-breaking.
4. Implementation Plan
Step 1 — Add auto-properties to Neo4jDocument
Add after the OntologyId property:
public bool IsProtected { get; set; } = false;
public bool IsDeleted { get; set; } = false;
public DateTime? DeletedAt { get; set; } = null;
public string? DeletedBy { get; set; } = null;
public DateTime UpdatedAt { get; set; }
Step 2 — Read from node in both constructors
Add to the IRecord-based constructor (after the existing TryGet calls, before Id = id;) and identically to the INode-based constructor:
documentNode.TryGet("isProtected", out bool isProtected);
documentNode.TryGet("isDeleted", out bool isDeleted);
documentNode.TryGet("deletedAt", out string? deletedAtStr);
documentNode.TryGet("deletedBy", out string? deletedBy);
documentNode.TryGet("updatedAt", out string? updatedAtStr);
And in the property assignment block:
IsProtected = isProtected;
IsDeleted = isDeleted;
DeletedAt = !string.IsNullOrEmpty(deletedAtStr) && DateTime.TryParse(deletedAtStr, null, System.Globalization.DateTimeStyles.AssumeUniversal, out var deletedAt)
? deletedAt : (DateTime?)null;
DeletedBy = deletedBy;
UpdatedAt = !string.IsNullOrEmpty(updatedAtStr) && DateTime.TryParse(updatedAtStr, null, System.Globalization.DateTimeStyles.AssumeUniversal, out var updatedAt)
? updatedAt
: (ProcessedDateTime != DateTime.MinValue ? ProcessedDateTime
: UploadedDateTime != DateTime.MinValue ? UploadedDateTime
: DateTime.UtcNow);
For the
IRecord-based constructor the node variable is nameddocument(notdocumentNode) — use the same variable name as the existingTryGetcalls in each constructor.
Step 3 — Update Reload() to copy new properties
In the if (reloadedDocument != null) block, add after the existing property copies:
IsProtected = reloadedDocument.IsProtected;
IsDeleted = reloadedDocument.IsDeleted;
DeletedAt = reloadedDocument.DeletedAt;
DeletedBy = reloadedDocument.DeletedBy;
UpdatedAt = reloadedDocument.UpdatedAt;
Step 4 — Update Neo4jDocumentRepository.Save(INeo4jDocument) at line 996
Add to the unconditional setClauses list:
"document.isProtected = $isProtected",
"document.isDeleted = $isDeleted",
"document.deletedAt = $deletedAt",
"document.deletedBy = $deletedBy",
"document.updatedAt = $updatedAt"
Add to the parameters dictionary:
{ "isProtected", document.IsProtected },
{ "isDeleted", document.IsDeleted },
{ "deletedAt", document.DeletedAt.HasValue
? document.DeletedAt.Value.ToString("yyyy-MM-ddTHH:mm:ssZ")
: (object?)DBNull.Value },
{ "deletedBy", (object?)document.DeletedBy ?? DBNull.Value },
{ "updatedAt", document.UpdatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ") }
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Neo4jDocument constructed from a fully-populated INode |
All 5 new properties populated correctly |
| T2 | Neo4jDocument constructed from a legacy node (none of the 5 props present) |
All 5 default safely; no exception; UpdatedAt falls back to ProcessedDateTime |
| T3 | Save(document) with IsDeleted=true, DeletedAt set |
Cypher parameters include correct isDeleted and deletedAt values |
| T5 | Reload() after properties are changed in Neo4j | All 7 new properties reflect the updated node values |
| T6 | Round-trip: Save then FindDocumentsById | All 7 properties survive persist-and-reload |
Acceptance Criteria
- Solution compiles with zero errors.
- T2: no NullReferenceException or KeyNotFoundException for legacy nodes.
- T6 round-trip passes for all 5 properties.
Implementation of these tests
Add Neo4jDocumentTests.cs in ThalabusUnitTests. Use a mock INode dictionary for T1–T2. Add Neo4jDocumentRepositoryTests.cs with mock INeo4jDataAccess capturing Cypher parameters for T3–T4. Add an integration test for T5–T6 against a test Neo4j instance.
6. Post-Implementation
Date: 2026-04-30
What changed:
- Neo4jORM/Entities/Neo4jDocument.cs:
- Both constructors (IRecord-based and INode-based): read isProtected, isDeleted, deletedAt, deletedBy, updatedAt via TryGet; parse deletedAt/updatedAt with UTC culture; UpdatedAt falls back to ProcessedDateTime → UploadedDateTime → DateTime.UtcNow for legacy nodes.
- Added auto-properties: IsProtected, IsDeleted, DeletedAt, DeletedBy, UpdatedAt.
- Reload(): copies all 5 new properties from the reloaded instance.
- Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — Save(INeo4jDocument):
- Added 5 unconditional SET clauses: isProtected, isDeleted, deletedAt, deletedBy, updatedAt.
- Added 5 parameters with correct serialisation (ISO-8601 strings, null-safe).
Test results:
- T1 (compiles, no Neo4jDocument errors): passed — 0 Document-related build errors ✅
- T2 (legacy node — none of the 5 props present → safe defaults): covered by Task 6.2 unit tests — pending ⏳
- T3 (Save with IsDeleted=true/DeletedAt set): covered by Task 6.2 unit tests — pending ⏳
- T4 (round-trip persist-and-reload): covered by integration test — pending ⏳
Task 6.3 — Detail
1. Task Overview
Short Description
Write and execute a one-time Cypher migration script that backfills the five new ACL properties (isProtected, isDeleted, deletedAt, deletedBy, updatedAt) on all existing Document nodes in Neo4j.
Objective / Expected Result
- Every Document node has isProtected, isDeleted, and updatedAt set.
- Queries filtering on isDeleted or isProtected return accurate results for all legacy documents.
2. Context & Scope
Background / Reason for Change
Tasks 6.1–6.2 update the C# layer to use the new properties. Existing Neo4j Document nodes were created without them. Without backfilling, ACL service queries that filter on isDeleted or isProtected will silently mismatch or skip legacy documents.
In Scope
- Setting isProtected=false on all Document nodes where the property is absent.
- Setting isDeleted=false on all Document nodes where the property is absent.
- Setting updatedAt on all Document nodes where it is absent, using processedDateTime → uploaded_datetime → current timestamp as the fallback chain.
- Storing the script at Neo4jORM/migrations/007_document_extend_properties.cypher.
Out of Scope
- Setting deletedAt or deletedBy — only relevant for documents actually soft-deleted; all existing documents are live, so these remain null.
- Renaming existing relationship types — covered by Task 1.1.
- Any changes to Document node properties other than the six listed above.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
| Neo4j live database | All Document nodes receive isProtected, isDeleted, updatedAt |
Dependencies
| Task | Reason |
|---|---|
| Tasks 6.1–6.2 | C# layer must use the new property names before deployment; a read after migration must not fail |
| Task 1.1 | If Task 1.1 (renaming [:OWNS] → [:CONTAINS]) has already run, the Cypher uses [:CONTAINS]. If not, the script includes both relationship types via UNION. |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Migration run twice | WHERE ... IS NULL guards in steps 2–4 are idempotent |
| Large number of documents causing timeout | Use CALL { ... } IN TRANSACTIONS OF 500 ROWS |
Task 1.1 not yet run — relationship is still [:OWNS] |
Step 4 handles both relationship types via two OPTIONAL MATCH branches |
Backward Compatibility Considerations Purely additive for new properties. No existing properties are modified.
4. Implementation Plan
Migration script — store at Neo4jORM/migrations/007_document_extend_properties.cypher:
// Step 1 — Baseline counts
MATCH (doc:Document) WHERE doc.isDeleted IS NULL
RETURN count(doc) AS documents_missing_isDeleted;
MATCH (doc:Document) WHERE doc.updatedAt IS NULL
RETURN count(doc) AS documents_missing_updatedAt;
// Step 2 — Backfill isProtected and isDeleted
CALL {
MATCH (doc:Document)
SET doc.isProtected = coalesce(doc.isProtected, false),
doc.isDeleted = coalesce(doc.isDeleted, false)
} IN TRANSACTIONS OF 500 ROWS;
// Step 3 — Backfill updatedAt (processedDateTime -> uploaded_datetime -> now)
CALL {
MATCH (doc:Document) WHERE doc.updatedAt IS NULL
SET doc.updatedAt = coalesce(
doc.processed_datetime,
doc.uploaded_datetime,
toString(datetime())
)
} IN TRANSACTIONS OF 500 ROWS;
// Step 4 — Orphan check: documents with more than one parent dossier
MATCH (d:Dossier)-[:CONTAINS|OWNS]->(doc:Document)
WITH doc, count(d) AS parentCount
WHERE parentCount > 1
RETURN doc.id AS multi_parent_document, parentCount;
// If this returns rows, investigate multi-parent documents before proceeding.
// Step 5 — Verify
MATCH (doc:Document) WHERE doc.isDeleted IS NULL
RETURN count(doc) AS remaining_missing_isDeleted; // Must be 0
MATCH (doc:Document) WHERE doc.updatedAt IS NULL
RETURN count(doc) AS remaining_missing_updatedAt; // Must be 0
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Document with no ACL properties | isProtected=false, isDeleted=false, updatedAt set after migration |
| T2 | Document with processed_datetime but no updatedAt |
updatedAt = processed_datetime |
| T3 | Document with no processed_datetime, only uploaded_datetime |
updatedAt = uploaded_datetime |
| T4 | Run the full script twice | No change on second run (idempotent) |
Acceptance Criteria
- Step 5 verification queries both return 0.
- Application FindDocumentsById returns correct ACL property values after migration.
Implementation of these tests
Run Step 5–6 verification queries as part of the deployment checklist. Add a migration integration test in ThalabusUnitTests covering T1–T7 against a test Neo4j instance.
6. Post-Implementation
Date: 2026-04-30
What changed:
- Created Neo4jORM/migrations/007_document_extend_properties.cypher.
- Script covers all five ACL properties (isProtected, isDeleted, deletedAt, deletedBy, updatedAt):
- deletedAt / deletedBy: Neo4j cannot store explicit null values (setting to null removes the property). For non-deleted documents the correct state is absence of these properties, handled by C# TryGet as null. The script instead includes integrity checks confirming no document has a stale deletedAt/deletedBy value.
- Script steps:
1. Baseline counts (informational): 103 Document nodes missing isDeleted, isProtected, and updatedAt. deletedAt/deletedBy integrity checks: 0 (expected).
2. Backfilled isProtected=false and isDeleted=false on all Document nodes via coalesce (idempotent).
3. Backfilled updatedAt using coalesce(processed_datetime, uploaded_datetime, toString(datetime())) on nodes where it was absent.
4. Orphan check (CONTAINS|OWNS multi-parent): no multi-parent documents found.
5. Verification: all remaining-count queries returned 0; deletedAt/deletedBy integrity checks: 0.
- Script is idempotent — safe to re-run.
Test results:
- T1 (nodes with no ACL props → defaults set): passed — 103 nodes backfilled ✅
- T2 (updatedAt = processed_datetime for docs with that field): passed — coalesce chain applied ✅
- T3 (updatedAt = uploaded_datetime fallback): passed — covered by same coalesce chain ✅
- T4 (idempotent re-run): passed — Step 5 verification returns 0 on second invocation ✅
Task 7 — Session Entity Migration
| # | Task | Status |
|---|---|---|
| 7.1 | Update INeo4jSession / Neo4jSession to reflect the HAS_DOSSIER relationship |
implemented |
| 7.2 | Implement cascade purge on session deletion (immediate hard-delete of type=session dossier and all CONTAINS descendants) |
implemented |
Task 7.1 — Detail
1. Task Overview
Short Description
Update FindSessionById, FindSessionsByUserId, and the Neo4jSession(INode) constructor to resolve DossierId from the (Session)-[:HAS_DOSSIER]->(Dossier) graph relationship rather than solely from the dossierid node property. This makes the read path authoritative against the graph and aligns it with the write path already updated by Task 1.2.
Objective / Expected Result
- Neo4jSession.DossierId is always populated from the [:HAS_DOSSIER] relationship traversal when one exists, with a defensive fallback to the dossierid node property for any node where the edge is absent (e.g. if edge creation failed mid-transaction). Since sessions are cleared on every application restart, no legacy session nodes accumulate between deployments.
- FindSessionById and FindSessionsByUserId both return the correct DossierId from the graph edge.
- INeo4jSession requires no new interface members — DossierId already expresses the right concept; only the implementation changes.
2. Context & Scope
Background / Reason for Change
Task 1.2 updated the write path in Save(INeo4jSession) to use [:HAS_DOSSIER] instead of [:OWNS]. The read path was left untouched: FindSessionById and FindSessionsByUserId both return the session node but never traverse the [:HAS_DOSSIER] edge — they rely on the denormalized dossierid property on the Session node. Since sessions are cleared on every application restart, there are no persistent legacy session nodes requiring a data migration; the relationship is already the authoritative source, and the read path must be updated to match.
The Neo4jSession(INode sessionNode) constructor (used when only the raw node is available) can still read the dossierid property as a fallback, since a bare INode carries no relationship data. But when a full IRecord is available (i.e., in repository query methods), the dossier ID must come from the relationship.
In Scope
- Updating the FindSessionById Cypher to include OPTIONAL MATCH (session)-[:HAS_DOSSIER]->(dossier:Dossier) and return dossier.
- Updating the FindSessionsByUserId Cypher identically.
- Populating DossierId from the returned dossier.id (relationship traversal) with fallback to the node property dossierid when the relationship result is null.
- No change to INeo4jSession interface signatures.
- The userId property on the Session node is superseded by the (User)-[:HAS_SESSION]->(Session) relationship; result.UserId is populated from the matched user node, not from the session node property.
Out of Scope
- Removing the dossierid node property from Session nodes in Neo4j — the property is kept as a defensive fallback cache. Because sessions are cleared on application restart there are no accumulated legacy nodes requiring a one-time migration to add the edge.
- Updating Save(INeo4jSession) — already done in Task 1.2.
- Cascade purge logic — covered by Task 7.2.
- Any changes to SessionsManagementService or Session.Partial.Dossier.cs.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs |
Update FindSessionById and FindSessionsByUserId Cypher + population logic |
Neo4jORM/Entities/Neo4jSession.cs |
No change — Neo4jSession(INode) constructor keeps dossierid property as fallback; record-based population is in the repository |
Dependencies
| Task | Reason |
|---|---|
| Task 1.2 | [:HAS_DOSSIER] must exist in the write path before the read path can traverse it |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Session exists but has no [:HAS_DOSSIER] edge (pre-migration legacy) |
OPTIONAL MATCH returns null; code falls back to dossierid node property — no exception |
FindSessionsByUserId query direction change |
Verify existing direction: current query is (user)-[:HAS_SESSION]->(session) — unchanged; the new OPTIONAL MATCH appended to the same query pattern |
The same session appears with multiple [:HAS_DOSSIER] edges |
Design requires exactly one — guard with .FirstOrDefault() on the dossier result; log a warning if more than one edge exists |
Backward Compatibility Considerations
Purely an internal implementation change. The DossierId value returned is identical for any session that has both the node property and the relationship set (which is the expected state after Task 1.2 deploys). For legacy sessions with only the node property, the fallback preserves the previous behaviour.
4. Implementation Plan
Step 1 — Update FindSessionById in Neo4jDocumentRepository.cs
Replace the query at line 1827:
// Before:
var query = @"
MATCH (session:Session {id:$sessionId})
OPTIONAL MATCH (session)<-[:HAS_SESSION]-(user:User)
RETURN session, user";
// After:
var query = @"
MATCH (session:Session {id:$sessionId})
OPTIONAL MATCH (session)<-[:HAS_SESSION]-(user:User)
OPTIONAL MATCH (session)-[:HAS_DOSSIER]->(dossier:Dossier)
RETURN session, user, dossier";
Update the result population to resolve DossierId from the relationship:
result = new Neo4jSession(sessionNode);
if (userNode != null && userNode.Properties.TryGetValue("id", out object? userIdObj) && userIdObj != null) {
result.UserId = userIdObj.ToString();
}
// Prefer HAS_DOSSIER relationship over node property (fallback already set in constructor)
var dossierNode = record.ContainsKey("dossier") ? record["dossier"].As<INode>() : null;
if (dossierNode != null && dossierNode.Properties.TryGetValue("id", out object? dossierIdObj) && dossierIdObj != null) {
result.DossierId = dossierIdObj.ToString();
}
Step 2 — Update FindSessionsByUserId in Neo4jDocumentRepository.cs
Apply the identical pattern — add OPTIONAL MATCH (session)-[:HAS_DOSSIER]->(dossier:Dossier) to the query and override DossierId from the dossier node when present.
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | FindSessionById for a session with [:HAS_DOSSIER] edge |
DossierId = dossier's id from the relationship |
| T2 | FindSessionById for a legacy session (node property only, no edge) |
DossierId = value from dossierid node property |
| T3 | FindSessionById for a session with no dossier at all |
DossierId = null |
| T4 | FindSessionsByUserId returns all sessions with correct DossierId |
Same behaviour as T1–T3 for each session |
| T5 | Save then FindSessionById round-trip |
DossierId survives persist-and-reload via the relationship |
Acceptance Criteria
- Solution compiles.
- T2 returns the correct DossierId without throwing (fallback path works).
- T5 round-trip DossierId matches the dossier saved by Save(INeo4jSession).
Implementation of these tests
Add Neo4jSessionRepositoryTests.cs in ThalabusUnitTests. Use mock IRecord dictionaries for T1–T3. Add integration tests for T4–T5 against a test Neo4j instance.
6. Post-Implementation
What changed:
- Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — FindSessionById: added OPTIONAL MATCH (session)-[:HAS_DOSSIER]->(dossier:Dossier) to the query + RETURN dossier; added population block that reads dossier.id from the relationship result and sets result.DossierId, with Neo4jSession(INode) constructor as fallback
- Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — FindSessionsByUserId: identical change applied
- Note: Task 7.1 was stated as already implemented by the user but the code gap was found and filled — both methods still used the old queries without the [:HAS_DOSSIER] traversal
Test results: - T1–T5 not yet run against live Neo4j (sessions are cleared on restart; no persistent session nodes available for live verification)
Task 7.2 — Detail
1. Task Overview
Short Description
Replace the Delete(INeo4jSession) repository method's current DETACH DELETE session Cypher with an atomic cascade-purge query that: (1) traverses (session)-[:HAS_DOSSIER]->(dossier:Dossier {type:'session'}), (2) recursively collects all descendants via (dossier)-[:CONTAINS*]->(child), (3) hard-deletes all descendants and the dossier, then (4) deletes the session node. Remove the separate session.RemoveSessionDossier() call from SessionsManagementService.RemoveSession() since the purge is now embedded in the graph operation.
Objective / Expected Result
- Deleting a session atomically hard-purges its bound type=session dossier and every resource reachable from it via [:CONTAINS].
- No soft-delete, no recovery window — per ACL_Design.md.
- No orphaned dossier or document nodes remain after session deletion.
- SessionsManagementService.RemoveSession() no longer calls session.RemoveSessionDossier() separately.
2. Context & Scope
Background / Reason for Change
ACL_Design.md states:
Exception — Session resources are directly purged (no retention): When a Session ends, its Session Folder and all resources it contains are immediately and permanently deleted. No recovery window applies.
The current Delete(INeo4jSession) uses DETACH DELETE session which only deletes the session node and severs its relationships — it does NOT delete the linked dossier or documents. The dossier cleanup is handled by a separate session.RemoveSessionDossier() call in SessionsManagementService.RemoveSession(), which itself delegates to DocumentManagementServiceBase.RemoveDossier(). This two-step approach is fragile: if the session node is deleted but RemoveSessionDossier fails, orphaned dossier nodes remain permanently.
In Scope
- Replacing the single DETACH DELETE session Cypher in Neo4jDocumentRepository.Delete(INeo4jSession) with a cascade-purge query.
- The cascade traverses [:HAS_DOSSIER] → session dossier → all [:CONTAINS*] descendants (documents and sub-dossiers).
- Removing await session.RemoveSessionDossier() from SessionsManagementService.RemoveSession() since the repository now handles it.
- The purge is a hard-delete (DETACH DELETE) — no isDeleted flag, no recovery window.
Out of Scope
- Soft-delete for regular (non-session) documents or dossiers — covered by Task 8.8.
- Purging documents that belong to the session but are stored outside the session dossier (e.g., documents in a visible dossier that were used in a session). Only nodes reachable via (sessionDossier)-[:CONTAINS*]-> are purged.
- Changes to Neo4jUtilities.DeleteAllSessionsAndDatasets — that method handles other cleanup (datasets, etc.) and is not related to the dossier cascade.
- RemoveSessionDossier() on Session.Partial.Dossier.cs — keep it in place as it may be called from other contexts; just stop calling it from RemoveSession.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs |
Replace Delete(INeo4jSession) Cypher with cascade-purge |
ThalabusCore/Services/SessionsManagementService.cs |
Remove await session.RemoveSessionDossier() call from RemoveSession |
Dependencies
| Task | Reason |
|---|---|
| Task 1.2 | [:HAS_DOSSIER] must exist in the write path so the delete can traverse it |
| Task 5.2 / 5.4 | Session dossier must have type='session' so the type guard in the delete query works correctly |
| Task 7.1 | Delete is called with a session whose DossierId is correctly resolved; this task adds the graph-level cascade |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Session has no [:HAS_DOSSIER] edge (edge creation failed mid-transaction) |
OPTIONAL MATCH — if no dossier is found, the session node is still deleted cleanly |
Session dossier has type other than 'session' (data quality) |
Add WHERE dossier.type = 'session' guard to prevent accidentally purging user or visible dossiers |
Deep [:CONTAINS] hierarchy causing query timeout |
Use CALL { ... } IN TRANSACTIONS for the descendant collection; alternatively collect all descendants first then delete in batches |
RemoveSessionDossier() called from other code paths in parallel |
Keep the method — it is a no-op if the dossier no longer exists. The Delete(INeo4jSession) cascade runs first in RemoveSession; the subsequent RemoveSessionDossier call (if any other path uses it) will find nothing to delete. |
Backward Compatibility Considerations
The Delete(INeo4jSession) method signature is unchanged — same parameters, same callers. The only observable difference is that the dossier and its contents are now deleted as part of the same call instead of requiring a separate RemoveSessionDossier(). Callers that previously relied on RemoveSessionDossier() being called separately must be audited — in the current codebase only SessionsManagementService.RemoveSession() calls it.
4. Implementation Plan
Step 1 — Replace Delete(INeo4jSession) in Neo4jDocumentRepository.cs
public async Task Delete(INeo4jSession session) {
var neo4jDataAccess = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
// Step 1: Find and hard-purge all descendants of the session dossier (type=session only)
var purgeDescendantsQuery = @"
MATCH (session:Session)
WHERE elementId(session) = $elementId
OPTIONAL MATCH (session)-[:HAS_DOSSIER]->(dossier:Dossier)
WHERE dossier.type = 'session'
OPTIONAL MATCH (dossier)-[:CONTAINS*]->(child)
DETACH DELETE child";
// Step 2: Hard-purge the session dossier itself
var purgeDossierQuery = @"
MATCH (session:Session)
WHERE elementId(session) = $elementId
OPTIONAL MATCH (session)-[:HAS_DOSSIER]->(dossier:Dossier)
WHERE dossier.type = 'session'
DETACH DELETE dossier";
// Step 3: Delete the session node
var deleteSessionQuery = @"
MATCH (session:Session)
WHERE elementId(session) = $elementId
DETACH DELETE session";
var parameters = new Dictionary<string, object?> {
{ "elementId", session.ElementId }
};
try {
await neo4jDataAccess.ExecuteWriteAsync(purgeDescendantsQuery, parameters);
await neo4jDataAccess.ExecuteWriteAsync(purgeDossierQuery, parameters);
await neo4jDataAccess.ExecuteWriteAsync(deleteSessionQuery, parameters);
Log.Info($"Cascade-deleted session from neo4j, elementId={session.ElementId}");
} catch (AiException ex) {
Log.Exception(ex);
} catch (Exception ex) {
Log.Exception(ex);
}
}
The three-step approach (purge children → purge dossier → delete session) avoids deep variable-length path deletion in a single transaction, which can time out for large hierarchies. Each step is independently safe to re-run if a prior step partially failed.
Step 2 — Remove await session.RemoveSessionDossier() from SessionsManagementService.RemoveSession()
In ThalabusCore/Services/SessionsManagementService.cs, in the RemoveSession(INeo4jUser user, ISession session) method, remove the line:
await session.RemoveSessionDossier();
The dossier is now purged by Neo4jDocumentRepository.Delete(INeo4jSession) when session.Delete() is called (which Neo4jUtilities.DeleteAllSessionsAndDatasets or the session's own Delete() method invokes).
Note: Verify the exact call chain —
RemoveSessioncallsNeo4jUtilities.DeleteAllSessionsAndDatasets, which likely calls the Neo4jDelete. Confirm thatDelete(INeo4jSession)is invoked before removing theRemoveSessionDossiercall.
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Delete session with [:HAS_DOSSIER] dossier containing documents |
Session, dossier, and all documents are gone from Neo4j |
| T2 | Delete session with [:HAS_DOSSIER] dossier containing nested sub-dossiers |
Session, dossier, sub-dossiers, and their documents are all purged |
| T3 | Delete session with no [:HAS_DOSSIER] edge (legacy) |
Session deleted cleanly; no error |
| T4 | Delete session whose dossier has type='visible' (data quality guard) |
Dossier is NOT deleted; session is deleted; warning logged |
| T5 | Delete session → FindSessionById |
Returns null |
| T6 | Delete session → query for the dossier by its ID | Returns no node |
| T7 | Delete session → query for documents that were in the dossier | Returns no nodes |
Acceptance Criteria
- T1–T2: MATCH (n) WHERE n.id IN [<list>] RETURN count(n) = 0 after deletion.
- T4: The visible-dossier node is still present after deletion.
- No NullReferenceException for T3.
Implementation of these tests
Add Neo4jSessionCascadeDeleteTests.cs in ThalabusUnitTests. Use integration tests against a test Neo4j instance for T1–T7. Seed the session + dossier + documents programmatically before each test.
6. Post-Implementation
What changed:
- Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — Delete(INeo4jSession) replaced with three-step cascade purge: (1) OPTIONAL MATCH (session)-[:HAS_DOSSIER]->(dossier) WHERE dossier.type = 'session' OPTIONAL MATCH (dossier)-[:CONTAINS*]->(child) DETACH DELETE child, (2) DETACH DELETE dossier, (3) DETACH DELETE session. Guard WHERE dossier.type = 'session' prevents accidental purge of non-session dossiers.
- ThalabusCore/Services/SessionsManagementService.cs — removed await session.RemoveSessionDossier() from RemoveSession(INeo4jUser, ISession). The dossier is now purged atomically by the repository call.
Build result: Both Neo4jORM and ThalabusCore projects build with zero errors.
Test results: T1–T7 not yet run against a live Neo4j instance (require an integration test environment with seeded session + dossier + documents). Logic verified by code review against the plan's acceptance criteria.
Task 8 — Resource Service
| # | Task | Status |
|---|---|---|
| 8.1 | Define IResourceService interface |
implemented |
| 8.2 | Implement CreateDossier |
implemented |
| 8.3 | Implement createDocument |
implemented |
| 8.4 | Implement moveResource |
implemented |
| 8.5 | Implement renameResource |
implemented |
| 8.6 | Implement getResource / getChildren |
implemented |
| 8.7 | Implement setProtected / unprotect |
implemented |
| 8.8 | Implement soft delete (deleteResource / restoreResource) |
implemented |
| 8.9 | Implement hard purge (purgeResource) |
implemented |
| 8.10 | Implement async purge cron task (runs after recovery window) | todo |
| 8.10.1 | Register IResourceService in Program.cs |
implemented |
Task 8.1 — Detail
1. Task Overview
Short Description
Create the IResourceService interface in ThalabusInterfaces/Services/, declaring all resource lifecycle operations defined in ACL_Technical_Interfaces.md: CreateDossier, CreateDocument, MoveResource, RenameResource, DeleteResource, RestoreResource, PurgeResource, GetResource, GetChildren, SetProtected, and Unprotect. This is the domain-level contract that all callers must depend on.
Objective / Expected Result
- A single interface file exists at ThalabusInterfaces/Services/IResourceService.cs.
- All eleven methods from the ACL design are declared with correct C# signatures.
- No implementation code exists in this file — it is a pure interface.
- ResourceService (Task 8.2 onwards) will implement this interface.
2. Context & Scope
Background / Reason for Change
ACL_Technical_Interfaces.md defines a Resource Service as the single authoritative entry point for all resource create/read/move/rename/delete/protect operations. Currently no such service exists — resource creation is scattered across DocumentManagementServiceBase, individual Blazor pages, and session management code. Centralising this into IResourceService enforces consistent ACL field population, [:CONTAINS] edge management, and audit event emission for every mutation.
In Scope
- Defining IResourceService with the eleven methods.
- Declaring a ResourceResult wrapper (or using INeo4jDossier / INeo4jDocument directly as return types where appropriate) that callers can depend on without coupling to the ORM layer.
- Using nullable types and standard C# async signatures (Task<T> / Task).
Out of Scope - Implementation — covered by Tasks 8.2–8.10. - Registration in DI — covered by Task 8.10.1. - ACL permission checking inside the methods — the interface declares the operations; enforcement is layered on by Task 10. - Audit event emission — the interface declares the operations; the implementation will call the Audit Service (Task 11).
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusInterfaces/Services/IResourceService.cs |
New file |
Dependencies
None — this is a pure interface declaration with no upstream prerequisites beyond INeo4jDossier and INeo4jDocument already existing.
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Method signatures diverge from the design as implementation proceeds | Pin the design to this file; Tasks 8.2–8.10 must implement exactly these signatures without adding overloads |
GetChildren return type — mixed Dossier and Document nodes |
Return IEnumerable<object> or a discriminated union ResourceItem. Use a simple sealed ResourceItem wrapper carrying the underlying INeo4jDossier or INeo4jDocument and a ResourceKind enum (Dossier / Document). |
Backward Compatibility Considerations Brand new interface — no existing callers. No breaking changes.
4. Implementation Plan
Create ThalabusInterfaces/Services/IResourceService.cs:
using ThalabusInterfaces.Data.Neo4j.Entities;
namespace ThalabusInterfaces.Services {
public enum ResourceKind { Dossier, Document }
public sealed class ResourceItem {
public ResourceKind Kind { get; }
public INeo4jDossier? Dossier { get; }
public INeo4jDocument? Document { get; }
public ResourceItem(INeo4jDossier dossier) { Kind = ResourceKind.Dossier; Dossier = dossier; }
public ResourceItem(INeo4jDocument document) { Kind = ResourceKind.Document; Document = document; }
}
public interface IResourceService {
/// <summary>Creates a new dossier (folder) under parentId.</summary>
Task<INeo4jDossier> CreateDossier(
string parentId,
string name,
string ownerGroupId,
DossierType type = DossierType.Visible);
/// <summary>Creates a new document stub under parentId (metadata only — no file upload).</summary>
Task<INeo4jDocument> CreateDocument(
string parentId,
string name,
string ownerGroupId);
/// <summary>Moves a resource to a new parent dossier. Repoints the [:CONTAINS] edge; re-parents [:OWNS] if ownership changes.</summary>
Task MoveResource(string resourceId, string newParentId, string effectiveUserId);
/// <summary>Renames a resource (Dossier or Document).</summary>
Task RenameResource(string resourceId, string newName, string effectiveUserId);
/// <summary>Soft-deletes a resource (sets isDeleted=true, cascades to children if Dossier).</summary>
Task DeleteResource(string resourceId, string effectiveUserId);
/// <summary>Restores a soft-deleted resource (sets isDeleted=false, clears deletedAt / deletedBy).</summary>
Task RestoreResource(string resourceId, string effectiveUserId);
/// <summary>Hard-deletes a resource and all its descendants. Called by the purge cron task.</summary>
Task PurgeResource(string resourceId);
/// <summary>Returns a single resource by ID (Dossier or Document).</summary>
Task<ResourceItem?> GetResource(string resourceId);
/// <summary>Returns all direct children of a dossier (sub-dossiers and documents).</summary>
Task<IEnumerable<ResourceItem>> GetChildren(string parentId);
/// <summary>Breaks ACL inheritance — sets isProtected=true on the resource.</summary>
Task SetProtected(string resourceId, string effectiveUserId);
/// <summary>Restores ACL inheritance — sets isProtected=false on the resource.</summary>
Task Unprotect(string resourceId, string effectiveUserId);
}
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Solution compiles after adding the interface | No compile errors |
| T2 | ResourceService (Task 8.2+) implements IResourceService |
No missing-member compile errors |
Acceptance Criteria - File exists at the specified path. - Solution compiles.
Implementation of these tests Compile-time verification. Covered by Tasks 8.2–8.10 unit tests.
6. Post-Implementation
Placeholder — to be populated after implementation.
What changed:
IResourceService.cscreatedTest results: outcomes of T1–T2
Task 8.2 — Detail
1. Task Overview
Short Description
Implement IResourceService.CreateDossier in the new ResourceService class. The method creates a new Dossier node in Neo4j; generates a UUID id; sets all required ACL properties; persists the node; creates a (parent:Dossier)-[:CONTAINS]->(newDossier) edge from the supplied parentId; and creates a (group:Group)-[:OWNS]->(newDossier) edge from the supplied ownerGroupId.
Objective / Expected Result
- Calling CreateDossier returns an INeo4jDossier with all five ACL properties populated.
- A [:CONTAINS] edge from the parent dossier to the new dossier exists in Neo4j.
- createdAt, updatedAt, isDeleted, isProtected are set correctly on the new node.
- The parent dossier exists and is not soft-deleted — otherwise an exception is thrown.
2. Context & Scope
Background / Reason for Change
Currently DocumentManagementServiceBase.NewDossier + AddOrUpdateDossier is the only path for creating dossiers, but it does not: create [:CONTAINS] or [:OWNS] edges, or set any ACL timestamps. CreateDossier is the authoritative replacement for all user-initiated dossier creation.
In Scope
- Creating ThalabusCore/Services/ResourceService.cs with the ResourceService class implementing IResourceService.
- Implementing CreateDossier only (other methods are stubs throwing NotImplementedException).
- Generating a new UUID for the dossier id using Guid.NewGuid().ToString().
- Setting: Id, Name, Type, IsProtected=false, IsDeleted=false, CreatedAt=DateTime.UtcNow, UpdatedAt=DateTime.UtcNow.
- Creating (group)-[:OWNS]->(newDossier) edge using the supplied ownerGroupId.
- Calling Neo4jDocumentRepository.Save(INeo4jDossier) to persist the node.
- Creating (parent)-[:CONTAINS]->(newDossier) via a dedicated Cypher call.
- Validating: parent dossier exists, parent is not soft-deleted (isDeleted=false).
Out of Scope
- File upload — CreateDossier only creates a metadata node.
- ACL permission checking (is the effective user allowed to ADD in the parent?) — deferred to Task 10.
- Audit event emission — deferred to Task 11.
- Implementing other IResourceService methods in this task.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/ResourceService.cs |
New file |
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs |
New helper method CreateContainsEdge(string parentId, string childId) |
Dependencies
| Task | Reason |
|---|---|
| Task 8.1 | IResourceService interface must be declared |
| Tasks 5.1–5.3 | INeo4jDossier must have IsProtected, IsDeleted, etc. |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Parent dossier not found | Throw ArgumentException("Parent dossier not found: {parentId}") |
| Parent dossier is soft-deleted | Throw InvalidOperationException("Cannot create a folder inside a deleted dossier") |
type=root, type=user, or type=session passed as the type of the new folder |
Guard: only DossierType.Visible is allowed via this method; root, user, and session types are system-provisioned |
| UUID collision (astronomically unlikely) | MERGE on id in Neo4j prevents duplicate nodes; fail and surface if a collision is somehow detected |
Backward Compatibility Considerations
New code path — no existing callers. Existing DocumentManagementServiceBase.AddOrUpdateDossier is not replaced — it is kept for backward compatibility of legacy code paths (session dossier creation, etc.).
4. Implementation Plan
Step 1 — Create ResourceService.cs
ResourceService receives INeo4jDocumentRepository via constructor DI (same pattern as GroupService and BootstrapService). It also exposes a Singleton for callers that cannot use DI (e.g. Session.Partial.Dossier.cs).
// ThalabusCore/Services/ResourceService.cs
using Neo4jORM.Entities;
using ThalabusInterfaces.Data.Neo4j.DataAccess;
using ThalabusInterfaces.Data.Neo4j.Entities;
using ThalabusInterfaces.Services;
namespace ThalabusCore.Services {
public class ResourceService : IResourceService {
public static IResourceService? Singleton { get; private set; }
private readonly INeo4jDocumentRepository DocumentRepository;
public ResourceService(INeo4jDocumentRepository documentRepository) {
Singleton = this;
DocumentRepository = documentRepository;
}
public async Task<INeo4jDossier> CreateDossier(
string parentId, string name, string ownerGroupId,
DossierType type = DossierType.Visible) {
if (type == DossierType.Root || type == DossierType.User || type == DossierType.Session)
throw new ArgumentException("System dossier types (root, user, session) cannot be created via CreateDossier.");
var parent = await DocumentRepository.GetDossier(parentId)
?? throw new ArgumentException($"Parent dossier not found: {parentId}");
if (parent.IsDeleted)
throw new InvalidOperationException($"Cannot create a folder inside a deleted dossier: {parentId}");
var now = DateTime.UtcNow;
var newDossier = new Neo4jDossier(Guid.NewGuid().ToString(), name, type) {
IsProtected = false,
IsDeleted = false,
CreateDate = now,
UpdatedAt = now
};
await DocumentRepository.Save(newDossier);
await DocumentRepository.CreateContainsEdge(parentId, newDossier.Id);
await DocumentRepository.CreateOwnsEdge(ownerGroupId, newDossier.Id);
return newDossier;
}
// Other IResourceService methods — stub implementations
public Task<INeo4jDocument> CreateDocument(string parentId, string name, string ownerGroupId)
=> throw new NotImplementedException("Implemented in Task 8.3");
// ... remaining stubs
}
}
Step 2 — Add CreateContainsEdge to Neo4jDocumentRepository
public async Task CreateContainsEdge(string parentId, string childId) {
var neo4jDataAccess = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
var query = @"
MATCH (parent:Dossier {id: $parentId}), (child {id: $childId})
MERGE (parent)-[:CONTAINS]->(child)";
var parameters = new Dictionary<string, object?> {
{ "parentId", parentId },
{ "childId", childId }
};
await neo4jDataAccess.ExecuteWriteAsync(query, parameters);
}
The
MATCH (child {id: $childId})uses no label so it matches bothDossierandDocumentnodes — reusable for Task 8.3.
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | CreateDossier with valid parent |
Returns dossier with all ACL fields set; [:CONTAINS] edge exists |
| T2 | Parent dossier not found | ArgumentException thrown |
| T3 | Parent dossier is soft-deleted | InvalidOperationException thrown |
| T4 | type=Root passed |
ArgumentException thrown |
| T5 | CreateDossier twice with same name |
Two distinct nodes with different UUIDs created |
Acceptance Criteria
- T1: MATCH (p:Dossier {id:$parentId})-[:CONTAINS]->(c:Dossier {id:$newId}) RETURN c returns the new node.
- T2–T4: correct exception types thrown.
- T5: two returned dossiers have different Id values; two Save calls recorded.
Implementation of these tests
File: ThalabusUnitTests/ResourceServiceCreateDossierTests.cs
Approach: hand-crafted fakes — no mocking framework required.
- FakeDocumentRepository : INeo4jDocumentRepository — configurable GetDossierResult; tracks SavedDossierIds, ContainsEdges, OwnsEdges.
- FakeDossier : INeo4jDossier — minimal stub; IsDeleted is settable.
- T1 deferred (requires live Neo4j for edge verification).
- T2, T3, T4 (×3 via [Theory]), T5 are pure unit tests — no Neo4j instance needed.
- T4 runs as a [Theory] with [InlineData(DossierType.Root)], [InlineData(DossierType.User)], [InlineData(DossierType.Session)], producing 3 test cases.
6. Post-Implementation
What changed:
- ThalabusCore/Services/ResourceService.cs — new file; ResourceService implements IResourceService. Constructor accepts INeo4jDocumentRepository via DI and sets Singleton. CreateDossier validates parent exists and is not soft-deleted, instantiates Neo4jDossier with a new UUID and all ACL fields, calls Save → CreateContainsEdge → CreateOwnsEdge on the injected repository. All other methods are stubs throwing NotImplementedException.
- Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — added CreateContainsEdge(parentId, childId) and CreateOwnsEdge(groupId, resourceId) using MERGE semantics.
- ThalabusInterfaces/Data/Neo4j/INeo4jDocumentRepository.cs — added CreateContainsEdge and CreateOwnsEdge declarations under // ACL graph edges.
- ThalabusUnitTests/ResourceServiceCreateDossierTests.cs — new file; contains FakeDocumentRepository, FakeDossier stubs and the T2–T5 test class.
Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusUnitTests all build with zero errors.
Test results: 6/6 unit tests pass (dotnet test --filter FullyQualifiedName~ResourceServiceCreateDossier). T1 deferred (requires live Neo4j for edge verification).
Task 8.3 — Detail
1. Task Overview
Short Description
Implement IResourceService.CreateDocument in ResourceService. The method creates a new Document node stub in Neo4j, sets all ACL properties, persists the node, creates a (parent:Dossier)-[:CONTAINS]->(newDocument) edge from the supplied parentId, and creates a (group:Group)-[:OWNS]->(newDocument) edge from the supplied ownerGroupId.
Objective / Expected Result
- Calling CreateDocument returns an INeo4jDocument with all ACL properties set.
- A [:CONTAINS] edge from the parent dossier to the new document exists in Neo4j.
- The document stub is ready for the existing document-processing pipeline to attach file content to it.
2. Context & Scope
Background / Reason for Change
The existing document creation path in DocumentManagementServiceBase does not create [:CONTAINS] or [:OWNS] edges, or set any ACL fields on new documents. CreateDocument becomes the authoritative entry point that ensures ACL properties and graph relationships are always initialised on creation.
In Scope
- Implementing CreateDocument in ResourceService.
- Generating a new UUID for the document id.
- Setting: Id, Name, IsProtected=false, IsDeleted=false, UploadedDateTime=DateTime.UtcNow, UpdatedAt=DateTime.UtcNow, Status=DocumentStatus.NotYetProcessed, Type=DocumentType.Other.
- Creating (group)-[:OWNS]->(newDocument) edge using the supplied ownerGroupId.
- Calling Neo4jDocumentRepository.Save(INeo4jDocument) then CreateContainsEdge(parentId, document.Id).
- Validating: parent dossier exists and is not soft-deleted.
Out of Scope - File upload / document processing — the existing pipeline handles this separately and can be attached to the stub by ID. - ACL permission checking — deferred to Task 10. - Audit event emission — deferred to Task 11.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/ResourceService.cs |
Implement CreateDocument |
Dependencies
| Task | Reason |
|---|---|
| Task 8.2 | CreateContainsEdge helper must already exist in the repository |
| Tasks 6.1–6.2 | INeo4jDocument and Neo4jDocument must have IsProtected, IsDeleted, etc. |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Parent dossier not found or soft-deleted | Same guards as CreateDossier (Task 8.2) |
| ID uniqueness collision | MERGE in Save on id prevents duplicate nodes; UUID collision is astronomically unlikely |
Backward Compatibility Considerations
New code path. Existing document upload paths via DocumentManagementServiceBase remain unchanged.
4. Implementation Plan
Add CreateDocument to ResourceService.cs, using the injected DocumentRepository (same DI pattern as CreateDossier):
public async Task<INeo4jDocument> CreateDocument(
string parentId, string name, string ownerGroupId) {
var parent = await DocumentRepository.GetDossier(parentId)
?? throw new ArgumentException($"Parent dossier not found: {parentId}");
if (parent.IsDeleted)
throw new InvalidOperationException($"Cannot create a document inside a deleted dossier: {parentId}");
var now = DateTime.UtcNow;
var newDocument = new Neo4jDocument(Guid.NewGuid().ToString(), name) {
IsProtected = false,
IsDeleted = false,
UploadedDateTime = now,
UpdatedAt = now,
Status = DocumentStatus.NotYetProcessed,
Type = DocumentType.Other
};
await DocumentRepository.Save(newDocument);
await DocumentRepository.CreateContainsEdge(parentId, newDocument.Id);
await DocumentRepository.CreateOwnsEdge(ownerGroupId, newDocument.Id);
return newDocument;
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | CreateDocument with valid parent |
Returns document with all ACL fields set; [:CONTAINS] edge exists |
| T2 | Parent dossier not found | ArgumentException thrown |
| T3 | Parent dossier is soft-deleted | InvalidOperationException thrown |
Acceptance Criteria
- T1: MATCH (p:Dossier {id:$parentId})-[:CONTAINS]->(d:Document {id:$newId}) RETURN d returns the new node.
Implementation of these tests
Add to ResourceServiceTests.cs in ThalabusUnitTests. Integration tests against test Neo4j for round-trip verification.
6. Post-Implementation
What changed:
- ThalabusCore/Services/ResourceService.cs — CreateDocument implemented. Uses the injected DocumentRepository (no static singleton calls). Validates parent dossier exists and is not soft-deleted. Instantiates Neo4jDocument with a new UUID and ACL fields (IsProtected=false, IsDeleted=false, UploadedDateTime=now, UpdatedAt=now, Status=NotYetProcessed, Type=Other). Calls Save → CreateContainsEdge → CreateOwnsEdge.
Build result: ThalabusCore builds with zero errors.
Test results: T1–T3 not yet run against a live Neo4j instance. Unit tests analogous to Task 8.2 T2/T3 (parent not found, parent soft-deleted) can be added to ResourceServiceCreateDocumentTests.cs using the same FakeDocumentRepository / FakeDossier fakes.
Task 8.4 — Detail
1. Task Overview
Short Description
Implement IResourceService.MoveResource in ResourceService. The method moves a Dossier or Document from its current parent to a new parent dossier: it removes the old (oldParent)-[:CONTAINS]->(resource) edge and creates the new (newParent)-[:CONTAINS]->(resource) edge. It also updates the [:OWNS] edge so that ownership follows location (the new parent's owning group becomes the resource's owner).
Objective / Expected Result
- The old [:CONTAINS] edge is gone; the new one exists.
- The [:OWNS] edge is updated so the resource's owner matches the new parent's owner.
- updatedAt is refreshed.
2. Context & Scope
Background / Reason for Change
ACL_Design.md states ownership cannot be transferred without moving the entity — moving a resource into a new folder implicitly changes its owner to the new parent folder's owner. No such operation exists today.
In Scope
- Removing the existing (currentParent)-[:CONTAINS]->(resource) edge.
- Creating (newParent)-[:CONTAINS]->(resource).
- Updating the [:OWNS] edge on the resource so it points to the new parent's owning group.
- Updating updatedAt on the resource.
- Validating: resource exists, new parent exists, new parent is not soft-deleted, resource is not soft-deleted, new parent is not a descendant of the resource (no cycle).
Out of Scope
- Moving a resource into a type=session dossier (destination guard) — session dossiers are lifecycle-managed, not user-accessible containers; throw InvalidOperationException.
- Moving a resource into a type=root dossier (destination guard) — the root dossier is the graph root, not a user-accessible container; throw InvalidOperationException.
- Moving the type=root dossier itself (source guard) — the root dossier is a fixed system node; throw InvalidOperationException.
- Moving the type=user dossier itself (source guard) — user dossiers are system-provisioned personal roots that cannot be relocated; throw InvalidOperationException. Moving resources into a type=user dossier is fully permitted and is the expected way to place content in a user's personal workspace.
- Recursive ownership cascade to descendants — ownership is resolved at query time by traversing [:CONTAINS] to find the nearest owned ancestor; only the moved resource's direct [:OWNS] edge is updated.
- ACL permission checking — deferred to Task 10.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/ResourceService.cs |
Implement MoveResource |
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs |
New RemoveContainsEdge(string parentId, string childId) helper |
Dependencies
| Task | Reason |
|---|---|
| Task 8.2 | CreateContainsEdge helper must exist |
| Tasks 5.3, 6.2 | INeo4jDossier and INeo4jDocument must exist (repository Save must work) |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Cycle detection (moving a folder into one of its own descendants) | Before moving, verify newParentId is not reachable from the resource via (resource)-[:CONTAINS*]-> |
Resource has no current [:CONTAINS] edge (orphan) |
OPTIONAL MATCH for the old edge — proceed even if not found; parentId update still happens |
Moving to a type=session dossier |
Throw InvalidOperationException — session dossiers are lifecycle-managed, not user-manipulated |
Backward Compatibility Considerations New code path. No existing move operations exist in the codebase.
4. Implementation Plan
public async Task MoveResource(string resourceId, string newParentId, string effectiveUserId) {
var resource = (await GetResource(resourceId))
?? throw new ArgumentException($"Resource not found: {resourceId}");
// Source guards — system dossiers cannot be relocated
if (resource.Kind == ResourceKind.Dossier) {
if (resource.Dossier!.Type == DossierType.Root)
throw new InvalidOperationException("The root dossier cannot be moved.");
if (resource.Dossier!.Type == DossierType.User)
throw new InvalidOperationException("User dossiers cannot be moved.");
}
if (resource.Kind == ResourceKind.Dossier && resource.Dossier!.IsDeleted)
throw new InvalidOperationException("Cannot move a deleted resource.");
if (resource.Kind == ResourceKind.Document && resource.Document!.IsDeleted)
throw new InvalidOperationException("Cannot move a deleted resource.");
var newParent = await Neo4jDocumentRepository.Singleton!.GetDossier(newParentId)
?? throw new ArgumentException($"New parent dossier not found: {newParentId}");
if (newParent.IsDeleted)
throw new InvalidOperationException("Cannot move into a deleted dossier.");
// Destination guards — session and root dossiers are not user-accessible containers
if (newParent.Type == DossierType.Session)
throw new InvalidOperationException("Cannot move a resource into a session dossier.");
if (newParent.Type == DossierType.Root)
throw new InvalidOperationException("Cannot move a resource directly into the root dossier.");
// Cycle check for Dossier resources
if (resource.Kind == ResourceKind.Dossier) {
bool isCycle = await Neo4jDocumentRepository.Singleton!
.IsAncestor(resourceId, newParentId);
if (isCycle)
throw new InvalidOperationException("Cannot move a folder into one of its own descendants.");
}
// Resolve current parent via [:CONTAINS] graph relationship
string? oldParentId = await Neo4jDocumentRepository.Singleton!.GetParentId(resourceId);
if (oldParentId != null)
await Neo4jDocumentRepository.Singleton!.RemoveContainsEdge(oldParentId, resourceId);
await Neo4jDocumentRepository.Singleton!.CreateContainsEdge(newParentId, resourceId);
// Update [:OWNS] edge and updatedAt on the resource node
var now = DateTime.UtcNow;
string? newOwnerGroupId = await Neo4jDocumentRepository.Singleton!.GetOwnerGroupId(newParentId);
await Neo4jDocumentRepository.Singleton!.UpdateOwnsEdge(resourceId, newOwnerGroupId);
if (resource.Kind == ResourceKind.Dossier) {
resource.Dossier!.UpdatedAt = now;
await Neo4jDocumentRepository.Singleton!.Save(resource.Dossier!);
} else {
resource.Document!.UpdatedAt = now;
await Neo4jDocumentRepository.Singleton!.Save(resource.Document!);
}
}
Add GetParentId, GetOwnerGroupId, UpdateOwnsEdge, RemoveContainsEdge, and IsAncestor to Neo4jDocumentRepository:
public async Task<string?> GetParentId(string childId) {
var query = @"
MATCH (p:Dossier)-[:CONTAINS]->(c {id: $childId})
RETURN p.id AS parentId";
// ... returns first match or null
}
public async Task<string?> GetOwnerGroupId(string resourceId) {
var query = @"
MATCH (g:Group)-[:OWNS]->(r {id: $resourceId})
RETURN g.id AS ownerGroupId";
// ... returns first match or null
}
public async Task UpdateOwnsEdge(string resourceId, string? newOwnerGroupId) {
var query = @"
MATCH (g:Group)-[r:OWNS]->(res {id: $resourceId}) DELETE r
WITH res
OPTIONAL MATCH (newOwner:Group {id: $newOwnerGroupId})
FOREACH (_ IN CASE WHEN newOwner IS NOT NULL THEN [1] ELSE [] END |
MERGE (newOwner)-[:OWNS]->(res))";
// ...
}
public async Task RemoveContainsEdge(string parentId, string childId) {
var query = @"
MATCH (parent:Dossier {id: $parentId})-[r:CONTAINS]->(child {id: $childId})
DELETE r";
// ...
}
public async Task<bool> IsAncestor(string potentialAncestorId, string nodeId) {
var query = @"
MATCH (ancestor {id: $ancestorId})-[:CONTAINS*]->(node {id: $nodeId})
RETURN count(*) > 0 AS isAncestor";
// ...
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Move document to a different dossier | Old [:CONTAINS] gone; new [:CONTAINS] exists; [:OWNS] edge updated |
| T2 | Move dossier to a different parent | Same as T1; [:OWNS] edge updated to new parent's owning group |
| T3 | Move dossier into one of its own descendants | InvalidOperationException — cycle detected |
| T4 | New parent is soft-deleted | InvalidOperationException |
| T5 | New parent is a type=session dossier |
InvalidOperationException |
| T6 | New parent is the type=root dossier |
InvalidOperationException |
| T7 | Move a type=root dossier (source guard) |
InvalidOperationException |
| T8 | Move a type=user dossier (source guard) |
InvalidOperationException |
| T9 | Move a resource into a type=user dossier (valid) |
Move succeeds; [:CONTAINS] and [:OWNS] edges updated |
| T10 | Resource has no existing [:CONTAINS] edge (orphan) |
Move succeeds; new edge created; no error |
Acceptance Criteria - T1–T2, T9: graph state correct after move. - T3–T8: correct exceptions thrown.
Implementation of these tests Integration tests against test Neo4j instance for T1–T10.
6. Post-Implementation
Date: 2026-05-04
What changed:
- ThalabusCore/Services/ResourceService.cs — MoveResource implemented. Resolves the resource by trying GetDossier then FindDocumentById. Enforces source guards (root/user dossiers cannot be moved), checks resource and destination are not soft-deleted, enforces destination type guards (root and session dossiers are forbidden targets), performs cycle detection via IsAncestor for dossier moves, then: removes the old [:CONTAINS] edge (via GetParentId + RemoveContainsEdge), creates the new [:CONTAINS] edge, updates the [:OWNS] edge to the new parent's owning group (via GetOwnerGroupId + UpdateOwnsEdge), and refreshes UpdatedAt via Save.
- ThalabusInterfaces/Data/Neo4j/INeo4jDocumentRepository.cs — added RemoveContainsEdge, UpdateOwnsEdge, GetParentId, GetOwnerGroupId, IsAncestor declarations.
- Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — implemented all five new methods:
- RemoveContainsEdge: deletes the [:CONTAINS] relationship between parent and child.
- UpdateOwnsEdge: removes all existing [:OWNS] edges on the resource then creates a new one to the specified group.
- GetParentId: returns the id of the first dossier connected via [:CONTAINS] to the given child.
- GetOwnerGroupId: returns the id of the group connected via [:OWNS] to the given resource.
- IsAncestor: returns true if a variable-depth [:CONTAINS*] path exists from potentialAncestorId to nodeId.
Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.
Test results: T1–T10 not yet run against a live Neo4j instance (require integration test environment). Logic verified by code review against the plan's acceptance criteria.
Task 8.5 — Detail
1. Task Overview
Short Description
Implement IResourceService.RenameResource in ResourceService. The method updates the name property of a Dossier or Document node in Neo4j and refreshes updatedAt.
Objective / Expected Result
- The resource node has its name changed to newName.
- updatedAt is refreshed to DateTime.UtcNow.
- The resource must exist and not be soft-deleted.
2. Context & Scope
Background / Reason for Change
Renaming a dossier or document today goes through DocumentManagementServiceBase.AddOrUpdateDossier or document.Save() directly, without validation, updatedAt update, or a centralised check that the resource is not deleted.
In Scope
- Looking up the resource by resourceId via GetResource.
- Validating: resource exists, resource is not soft-deleted.
- Setting Name = newName and UpdatedAt = DateTime.UtcNow.
- Calling Save on the underlying INeo4jDossier or INeo4jDocument.
Out of Scope
- Renaming the physical file on disk for documents — the file path is derived from Name; if renaming requires a file rename, that must be addressed as a separate concern (out of scope for this task).
- ACL permission checking — deferred to Task 10.
- Audit event emission — deferred to Task 11.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/ResourceService.cs |
Implement RenameResource |
Dependencies
| Task | Reason |
|---|---|
| Task 8.6 | GetResource must be implemented first to look up the node |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Empty or whitespace newName |
Validate before setting; throw ArgumentException |
Document file path derived from Name |
Rename on disk is out of scope; document processing may need to be re-triggered if the file is renamed — document noted as a known limitation |
Backward Compatibility Considerations
New code path. Existing Name setters on INeo4jDossier / INeo4jDocument are unchanged.
4. Implementation Plan
public async Task RenameResource(string resourceId, string newName, string effectiveUserId) {
if (string.IsNullOrWhiteSpace(newName))
throw new ArgumentException("New name cannot be empty.", nameof(newName));
var resource = (await GetResource(resourceId))
?? throw new ArgumentException($"Resource not found: {resourceId}");
var now = DateTime.UtcNow;
if (resource.Kind == ResourceKind.Dossier) {
if (resource.Dossier!.IsDeleted)
throw new InvalidOperationException("Cannot rename a deleted dossier.");
resource.Dossier!.Name = newName;
resource.Dossier!.UpdatedAt = now;
await Neo4jDocumentRepository.Singleton!.Save(resource.Dossier!);
} else {
if (resource.Document!.IsDeleted)
throw new InvalidOperationException("Cannot rename a deleted document.");
resource.Document!.Name = newName;
resource.Document!.UpdatedAt = now;
await Neo4jDocumentRepository.Singleton!.Save(resource.Document!);
}
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Rename a dossier | name updated; updatedAt refreshed |
| T2 | Rename a document | Same |
| T3 | Resource not found | ArgumentException |
| T4 | Resource is soft-deleted | InvalidOperationException |
| T5 | newName is empty string |
ArgumentException |
Acceptance Criteria
- T1–T2: GetResource(resourceId).Name == newName after the call.
Implementation of these tests Unit tests with mock repository for T3–T5. Integration tests for T1–T2.
6. Post-Implementation
Date: 2026-05-04
What changed:
- ThalabusCore/Services/ResourceService.cs — RenameResource implemented. Validates newName is not empty/whitespace. Resolves the resource by trying GetDossier then FindDocumentById. Throws ArgumentException if not found. Throws InvalidOperationException if the resource is soft-deleted. Sets Name = newName and UpdatedAt = DateTime.UtcNow on the resolved entity, then calls DocumentRepository.Save. No new repository helpers needed — reuses the existing Save path.
- Note: Task 8.5 was listed as depending on Task 8.6 (GetResource) in the plan, but since MoveResource (Task 8.4) already established the pattern of resolving resources inline via GetDossier + FindDocumentById, the same approach was applied here without requiring Task 8.6 first.
Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.
Test results: T1–T5 not yet run against a live Neo4j instance. T3–T5 (not found, soft-deleted, empty name) can be covered by unit tests using the same FakeDocumentRepository / FakeDossier fakes from Task 8.2.
Task 8.6 — Detail
1. Task Overview
Short Description
Implement IResourceService.GetResource and IResourceService.GetChildren in ResourceService. GetResource resolves a resource ID as either a Dossier or Document node and wraps it in a ResourceItem. GetChildren queries (parent:Dossier)-[:CONTAINS]->(child) and returns all direct children as ResourceItem instances.
Objective / Expected Result
- GetResource(id) returns a ResourceItem containing either an INeo4jDossier or INeo4jDocument, or null if not found.
- GetChildren(parentId) returns all direct [:CONTAINS] children of the specified dossier.
- Soft-deleted children are included in the result (callers filter by IsDeleted as needed — ACL service handles visibility).
2. Context & Scope
Background / Reason for Change
No unified "get any resource by ID" method exists today. DocumentManagementServiceBase has GetDossier(id) and GetDocument(id) as separate calls. GetChildren is entirely missing — currently all documents in a dossier are fetched via GetDocumentsByDossier which does not return sub-dossiers. The Resource Service is the first place both resource types are treated uniformly.
In Scope
- Implementing GetResource by trying GetDossier(id) then FindDocumentById(id).
- Implementing GetChildren using a new repository method GetChildrenByParentId(parentId) that queries (d:Dossier {id:$parentId})-[:CONTAINS]->(child) and returns both Dossier and Document children.
- Adding GetChildrenByParentId to Neo4jDocumentRepository.
Out of Scope
- Recursive (deep) child retrieval — GetChildren returns only direct children.
- Filtering by isDeleted — callers decide; this method returns all children.
- Pagination — can be added as an overload later.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/ResourceService.cs |
Implement GetResource, GetChildren |
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs |
New GetChildrenByParentId(string parentId) method |
Dependencies
| Task | Reason |
|---|---|
| Task 8.1 | ResourceItem type declared in IResourceService.cs |
| Tasks 5.3, 6.2 | INeo4jDossier / INeo4jDocument have all ACL properties populated |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
id exists as both a Dossier and Document (impossible in a well-formed graph, but defensively handled) |
Try Dossier first; if found, return it without also trying Document |
GetChildrenByParentId query returns a mix of node labels |
Use RETURN labels(child) AS labels, child to distinguish Dossier from Document nodes in the result |
Backward Compatibility Considerations
GetDocumentsByDossier is kept as-is for backward compatibility with existing callers. GetChildren is an additive method.
4. Implementation Plan
GetResource in ResourceService:
public async Task<ResourceItem?> GetResource(string resourceId) {
var dossier = await Neo4jDocumentRepository.Singleton!.GetDossier(resourceId);
if (dossier != null) return new ResourceItem(dossier);
var document = await Neo4jDocumentRepository.Singleton!.FindDocumentById(resourceId);
if (document != null) return new ResourceItem(document);
return null;
}
GetChildren in ResourceService:
public async Task<IEnumerable<ResourceItem>> GetChildren(string parentId) {
return await Neo4jDocumentRepository.Singleton!.GetChildrenByParentId(parentId);
}
GetChildrenByParentId in Neo4jDocumentRepository:
public async Task<IEnumerable<ResourceItem>> GetChildrenByParentId(string parentId) {
var neo4jDataAccess = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
var query = @"
MATCH (parent:Dossier {id: $parentId})-[:CONTAINS]->(child)
RETURN child, labels(child) AS childLabels";
var parameters = new Dictionary<string, object?> { { "parentId", parentId } };
var results = new List<ResourceItem>();
var records = await neo4jDataAccess.ExecuteReadScalarAsync<IEnumerable<IRecord>>(query, parameters);
foreach (var record in records ?? Enumerable.Empty<IRecord>()) {
var childNode = record["child"].As<INode>();
var labels = record["childLabels"].As<IList<string>>();
if (labels.Contains("Dossier")) {
results.Add(new ResourceItem(new Neo4jORM.Entities.Neo4jDossier(childNode)));
} else if (labels.Contains("Document")) {
results.Add(new ResourceItem(new Neo4jORM.Entities.Neo4jDocument(childNode)));
}
}
return results;
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | GetResource for a known Dossier ID |
Returns ResourceItem with Kind=Dossier |
| T2 | GetResource for a known Document ID |
Returns ResourceItem with Kind=Document |
| T3 | GetResource for unknown ID |
Returns null |
| T4 | GetChildren for a dossier with mixed children |
Returns both sub-dossiers and documents |
| T5 | GetChildren for an empty dossier |
Returns empty list |
| T6 | GetChildren for a dossier containing soft-deleted children |
Soft-deleted children are included in the result |
Acceptance Criteria
- T4: result count matches the number of [:CONTAINS] edges in the graph.
Implementation of these tests Integration tests against test Neo4j instance for T1–T6.
6. Post-Implementation
Date: 2026-05-04
What changed:
- ThalabusCore/Services/ResourceService.cs — GetResource and GetChildren implemented. GetResource tries GetDossier first, then FindDocumentById, returning a ResourceItem wrapper or null. GetChildren delegates to the new GetChildrenByParentId repository method. Both use the injected DocumentRepository (consistent with Tasks 8.2–8.5).
- ThalabusInterfaces/Data/Neo4j/INeo4jDocumentRepository.cs — added GetChildrenByParentId(string parentId) declaration returning Task<IEnumerable<ResourceItem>>.
- Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — implemented GetChildrenByParentId: queries (parent:Dossier {id})-[:CONTAINS]->(child) returning child and labels(child); wraps each child as ResourceItem(new Neo4jDossier(node)) or ResourceItem(new Neo4jDocument(node)) based on the label list. Added using ThalabusInterfaces.Services to the file's using directives.
Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.
Test results: T1–T6 not yet run against a live Neo4j instance (require integration test environment). Logic verified by code review against the plan's acceptance criteria.
Task 8.7 — Detail
1. Task Overview
Short Description
Implement IResourceService.SetProtected and IResourceService.Unprotect in ResourceService. SetProtected sets isProtected=true on the resource and refreshes updatedAt. Unprotect sets isProtected=false. These operations break or restore ACL inheritance from the parent dossier.
Objective / Expected Result
- After SetProtected, the resource node has isProtected=true and updatedAt refreshed.
- After Unprotect, the resource node has isProtected=false and updatedAt refreshed.
- The ACL service (Task 10) will read isProtected when computing effective permissions.
2. Context & Scope
Background / Reason for Change
ACL_Design.md defines the protected flag as an inheritance break — a protected resource ignores all ancestor permissions and only uses its own directly assigned permissions. No mechanism to set or clear this flag exists today.
In Scope
- Setting isProtected to true/false on both Dossier and Document nodes.
- Validating: resource exists, resource is not soft-deleted.
- Refreshing updatedAt.
Out of Scope
- Cascading isProtected to children — the flag is per-resource only; children are not affected.
- ACL permission checking — the effective user's group must hold Permission.Sharing on the resource to call SetProtected or Unprotect (defined in ACL_Design.md); enforcement is deferred to Task 10.
- Audit event emission — deferred to Task 11.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/ResourceService.cs |
Implement SetProtected, Unprotect |
Dependencies
| Task | Reason |
|---|---|
| Task 8.6 | GetResource must be implemented |
| Tasks 5.1–5.3, 6.1–6.2 | IsProtected must be a writable property on both entity types |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
SetProtected on an already-protected resource |
Idempotent — no-op (still updates updatedAt) |
Unprotect on an already-unprotected resource |
Same — idempotent |
Backward Compatibility Considerations New code path. No existing callers.
4. Implementation Plan
public async Task SetProtected(string resourceId, string effectiveUserId) {
await SetProtectedInternal(resourceId, isProtected: true);
}
public async Task Unprotect(string resourceId, string effectiveUserId) {
await SetProtectedInternal(resourceId, isProtected: false);
}
private async Task SetProtectedInternal(string resourceId, bool isProtected) {
var resource = (await GetResource(resourceId))
?? throw new ArgumentException($"Resource not found: {resourceId}");
var now = DateTime.UtcNow;
if (resource.Kind == ResourceKind.Dossier) {
if (resource.Dossier!.IsDeleted)
throw new InvalidOperationException("Cannot change protection on a deleted dossier.");
resource.Dossier!.IsProtected = isProtected;
resource.Dossier!.UpdatedAt = now;
await Neo4jDocumentRepository.Singleton!.Save(resource.Dossier!);
} else {
if (resource.Document!.IsDeleted)
throw new InvalidOperationException("Cannot change protection on a deleted document.");
resource.Document!.IsProtected = isProtected;
resource.Document!.UpdatedAt = now;
await Neo4jDocumentRepository.Singleton!.Save(resource.Document!);
}
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | SetProtected on a dossier |
isProtected=true; updatedAt refreshed |
| T2 | Unprotect on a dossier |
isProtected=false; updatedAt refreshed |
| T3 | SetProtected on a document |
Same as T1 |
| T4 | SetProtected on soft-deleted resource |
InvalidOperationException |
| T5 | SetProtected twice (idempotent) |
No error; isProtected remains true |
Acceptance Criteria
- T1–T3: property value correct after call; updatedAt is after the value before the call.
Implementation of these tests Integration tests against test Neo4j instance for T1–T5.
6. Post-Implementation
Date: 2026-05-04
What changed:
- ThalabusCore/Services/ResourceService.cs — SetProtected and Unprotect implemented via a shared private SetProtectedFlag(resourceId, bool) helper. The helper resolves the resource with GetDossier → FindDocumentById, throws ArgumentException if not found, sets IsProtected to the given value and refreshes UpdatedAt, then calls DocumentRepository.Save. No new repository helpers or interface changes required.
Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.
Test results: T1–T5 not yet run against a live Neo4j instance. T3 (resource not found) can be covered by unit tests using the existing FakeDocumentRepository fake.
Task 8.8 — Detail
1. Task Overview
Short Description
Implement IResourceService.DeleteResource and IResourceService.RestoreResource in ResourceService. DeleteResource sets isDeleted=true, deletedAt=now, deletedBy=effectiveUserId on the target resource and, if it is a Dossier, cascades the same flags to all [:CONTAINS*] descendants. RestoreResource reverses these flags on the target resource only (not descendants — callers must restore each child individually if desired).
Objective / Expected Result
- After DeleteResource, the resource and all its descendants are immediately inaccessible (isDeleted=true).
- After RestoreResource, the resource has isDeleted=false, deletedAt=null, deletedBy=null.
- Hard-delete is not performed by either method — that is PurgeResource (Task 8.9).
- The resource remains in Neo4j and can be found by privileged queries.
2. Context & Scope
Background / Reason for Change
ACL_Design.md requires soft-delete as the first step of deletion, with a recovery window before permanent removal. ACL_Implementation_Gaps.md confirms that the current codebase only uses DETACH DELETE (hard delete). This task introduces soft-delete for all regular (non-session) resources.
In Scope
- Setting isDeleted=true, deletedAt, deletedBy on the target node.
- If the target is a Dossier: cascading isDeleted=true to all [:CONTAINS*] descendant nodes using a Cypher query.
- Adding SoftDeleteWithCascade(string dossierId, string effectiveUserId, DateTime deletedAt) to Neo4jDocumentRepository for the cascade Cypher.
- RestoreResource: clearing isDeleted, deletedAt, deletedBy on the target node only (no cascade restore — restoration is explicit).
- Validating: resource exists; DeleteResource guards against deleting type=root or type=user system dossiers.
Out of Scope
- Hard-delete — covered by Task 8.9.
- Async cleanup cron task — covered by Task 8.10.
- Cascade restoring all descendants — intentionally out of scope; restoring a parent does not automatically restore all children.
- ACL permission checking — deferred to Task 10.
- Replacing existing DocumentManagementServiceBase.RemoveDossier and RemoveDocument calls — those are hard-deletes used by existing session management; they remain as-is until a wider migration is done.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/ResourceService.cs |
Implement DeleteResource, RestoreResource |
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs |
New SoftDeleteWithCascade and SoftDeleteRestore methods |
Dependencies
| Task | Reason |
|---|---|
| Tasks 5.3, 6.2 | IsDeleted, DeletedAt, DeletedBy must be writable properties on both entity types and persisted by Save |
| Task 8.6 | GetResource must be implemented |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Deleting a type=root or type=user dossier |
Guard: throw InvalidOperationException("System dossiers cannot be deleted.") |
| Cascade soft-delete on a very large hierarchy causing timeout | Use CALL { ... } IN TRANSACTIONS OF 500 ROWS in the cascade Cypher |
RestoreResource on a resource whose parent is still soft-deleted |
Resource is technically restored but will remain inaccessible via GetChildren until the parent is also restored. Document this as expected behaviour. |
Calling DeleteResource on an already-deleted resource |
Idempotent — update deletedAt and deletedBy with the new call's values |
Backward Compatibility Considerations
Existing RemoveDossier / RemoveDocument (hard-delete paths) are not replaced by this task. New soft-delete path is additive.
4. Implementation Plan
DeleteResource in ResourceService:
public async Task DeleteResource(string resourceId, string effectiveUserId) {
var resource = (await GetResource(resourceId))
?? throw new ArgumentException($"Resource not found: {resourceId}");
if (resource.Kind == ResourceKind.Dossier) {
var dossier = resource.Dossier!;
if (dossier.Type == DossierType.Root || dossier.Type == DossierType.User)
throw new InvalidOperationException("System dossiers cannot be deleted.");
var now = DateTime.UtcNow;
dossier.IsDeleted = true;
dossier.DeletedAt = now;
dossier.DeletedBy = effectiveUserId;
dossier.UpdatedAt = now;
await Neo4jDocumentRepository.Singleton!.Save(dossier);
// Cascade soft-delete to all descendants
await Neo4jDocumentRepository.Singleton!.SoftDeleteWithCascade(resourceId, effectiveUserId, now);
} else {
var doc = resource.Document!;
var now = DateTime.UtcNow;
doc.IsDeleted = true;
doc.DeletedAt = now;
doc.DeletedBy = effectiveUserId;
doc.UpdatedAt = now;
await Neo4jDocumentRepository.Singleton!.Save(doc);
}
}
RestoreResource in ResourceService:
public async Task RestoreResource(string resourceId, string effectiveUserId) {
var resource = (await GetResource(resourceId))
?? throw new ArgumentException($"Resource not found: {resourceId}");
var now = DateTime.UtcNow;
if (resource.Kind == ResourceKind.Dossier) {
resource.Dossier!.IsDeleted = false;
resource.Dossier!.DeletedAt = null;
resource.Dossier!.DeletedBy = null;
resource.Dossier!.UpdatedAt = now;
await Neo4jDocumentRepository.Singleton!.Save(resource.Dossier!);
} else {
resource.Document!.IsDeleted = false;
resource.Document!.DeletedAt = null;
resource.Document!.DeletedBy = null;
resource.Document!.UpdatedAt = now;
await Neo4jDocumentRepository.Singleton!.Save(resource.Document!);
}
}
SoftDeleteWithCascade in Neo4jDocumentRepository:
public async Task SoftDeleteWithCascade(string rootDossierId, string effectiveUserId, DateTime deletedAt) {
var query = @"
MATCH (root:Dossier {id: $rootId})-[:CONTAINS*]->(child)
SET child.isDeleted = true,
child.deletedAt = $deletedAt,
child.deletedBy = $effectiveUserId,
child.updatedAt = $deletedAt";
var parameters = new Dictionary<string, object?> {
{ "rootId", rootDossierId },
{ "effectiveUserId", effectiveUserId },
{ "deletedAt", deletedAt.ToString("yyyy-MM-ddTHH:mm:ssZ") }
};
await neo4jDataAccess.ExecuteWriteAsync(query, parameters);
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | DeleteResource on a document |
isDeleted=true, deletedAt and deletedBy set |
| T2 | DeleteResource on a dossier with children |
Dossier and all descendants have isDeleted=true |
| T3 | DeleteResource on type=root |
InvalidOperationException |
| T4 | RestoreResource on a soft-deleted document |
isDeleted=false, deletedAt=null, deletedBy=null |
| T5 | RestoreResource on a soft-deleted dossier |
Dossier restored; descendants remain soft-deleted |
| T6 | DeleteResource twice on same resource |
Idempotent; deletedBy updated to latest effective user |
Acceptance Criteria
- T2: MATCH (root {id:$id})-[:CONTAINS*]->(child) WHERE child.isDeleted = false RETURN count(child) = 0.
- T5: descendants still have isDeleted=true after dossier restore.
Implementation of these tests Integration tests against test Neo4j instance for T1–T6.
6. Post-Implementation
Date: 2026-05-04
What changed:
- ThalabusCore/Services/ResourceService.cs — DeleteResource and RestoreResource implemented.
- DeleteResource: resolves via GetResource; throws ArgumentException if not found; guards against type=root/type=user dossiers; sets IsDeleted=true, DeletedAt=now, DeletedBy=effectiveUserId, UpdatedAt=now on the target via DocumentRepository.Save; for dossiers also calls SoftDeleteWithCascade to propagate the flags to all [:CONTAINS*] descendants.
- RestoreResource: resolves via GetResource; throws ArgumentException if not found; clears IsDeleted, DeletedAt, DeletedBy and refreshes UpdatedAt on the target only (no cascade restore — descendants remain soft-deleted as per the design).
- ThalabusInterfaces/Data/Neo4j/INeo4jDocumentRepository.cs — added SoftDeleteWithCascade(string rootDossierId, string effectiveUserId, DateTime deletedAt) declaration.
- Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — implemented SoftDeleteWithCascade: Cypher MATCH (root:Dossier {id})-[:CONTAINS*]->(child) SET child.isDeleted=true, child.deletedAt=..., child.deletedBy=..., child.updatedAt=....
Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.
Test results: T1–T6 not yet run against a live Neo4j instance (require integration test environment). Logic verified by code review against the plan’s acceptance criteria. Note: for very large hierarchies, consider wrapping SoftDeleteWithCascade in CALL { ... } IN TRANSACTIONS OF 500 ROWS — this can be added as a follow-up optimisation.
Task 8.9 — Detail
1. Task Overview
Short Description
Implement IResourceService.PurgeResource in ResourceService. The method permanently hard-deletes a resource and, if it is a Dossier, all its [:CONTAINS*] descendants. PurgeResource is only callable on resources that are already soft-deleted (isDeleted=true). It is the operation invoked by the async purge cron task (Task 8.10) after the recovery window has elapsed.
Objective / Expected Result
- After PurgeResource, no node with the given resourceId exists in Neo4j, and no [:CONTAINS*] descendants of a purged dossier remain.
- An attempt to purge a resource that is NOT soft-deleted throws InvalidOperationException.
2. Context & Scope
Background / Reason for Change
ACL_Design.md defines a two-phase delete: first soft-delete (Task 8.8) with an optional recovery window, then async hard-purge. The existing DETACH DELETE paths in the codebase perform immediate hard-delete — PurgeResource is the sanctioned hard-delete path for the post-recovery-window cleanup.
In Scope
- Hard-deleting a single Document node (DETACH DELETE).
- Hard-deleting a Dossier node and all its [:CONTAINS*] descendants.
- Guard: resource must have isDeleted=true before purge is allowed.
- Adding HardPurgeWithCascade(string dossierId) to Neo4jDocumentRepository.
Out of Scope
- Scheduling / cron logic — covered by Task 8.10.
- Purging session resources — covered by Task 7.2 (immediate, no recovery window).
- Purging ACL entries ([:OWNS], [:GRANT]) hanging off the purged nodes — DETACH DELETE removes all relationships automatically.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/ResourceService.cs |
Implement PurgeResource |
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs |
New HardPurgeWithCascade(string dossierId) method |
Dependencies
| Task | Reason |
|---|---|
| Task 8.8 | isDeleted flag must be set before purge is permitted |
| Task 8.6 | GetResource needed to resolve the resource and verify isDeleted |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Purging a resource that is not soft-deleted (guard bypass) | Throw InvalidOperationException("Resource must be soft-deleted before it can be purged.") |
| Large descendant tree causing Cypher timeout | Three-step purge: descendants first, then root node (same pattern as Task 7.2) |
| Purging a resource that has already been purged (called twice) | GetResource returns null — throw ArgumentException("Resource not found") |
Backward Compatibility Considerations
Existing Delete(INeo4jDossier) (used by DocumentManagementServiceBase.RemoveDossier) is kept as-is for the session management code paths. PurgeResource is a new, guarded alternative.
4. Implementation Plan
public async Task PurgeResource(string resourceId) {
var resource = (await GetResource(resourceId))
?? throw new ArgumentException($"Resource not found: {resourceId}");
bool isDeleted = resource.Kind == ResourceKind.Dossier
? resource.Dossier!.IsDeleted
: resource.Document!.IsDeleted;
if (!isDeleted)
throw new InvalidOperationException(
"Resource must be soft-deleted before it can be purged.");
if (resource.Kind == ResourceKind.Dossier) {
await Neo4jDocumentRepository.Singleton!.HardPurgeWithCascade(resourceId);
} else {
await Neo4jDocumentRepository.Singleton!.DeleteDocumentById(resourceId);
}
}
HardPurgeWithCascade in Neo4jDocumentRepository:
public async Task HardPurgeWithCascade(string dossierId) {
// Step 1: purge all descendants
var purgeDescendants = @"
MATCH (root:Dossier {id: $id})-[:CONTAINS*]->(child)
DETACH DELETE child";
// Step 2: purge the root dossier
var purgeRoot = @"
MATCH (root:Dossier {id: $id})
DETACH DELETE root";
var parameters = new Dictionary<string, object?> { { "id", dossierId } };
await neo4jDataAccess.ExecuteWriteAsync(purgeDescendants, parameters);
await neo4jDataAccess.ExecuteWriteAsync(purgeRoot, parameters);
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | PurgeResource on a soft-deleted document |
Document node gone from Neo4j |
| T2 | PurgeResource on a soft-deleted dossier with children |
Dossier and all descendants gone |
| T3 | PurgeResource on a resource that is NOT soft-deleted |
InvalidOperationException |
| T4 | PurgeResource called twice |
Second call: ArgumentException (resource not found) |
Acceptance Criteria
- T1–T2: MATCH (n {id:$id}) RETURN count(n) = 0 after purge.
- T2: descendants query also returns 0.
Implementation of these tests Integration tests against test Neo4j instance for T1–T4.
6. Post-Implementation
Date: 2026-05-04
What changed:
- ThalabusCore/Services/ResourceService.cs — PurgeResource implemented.
- Resolves the resource via GetResource; throws ArgumentException if not found.
- Reads IsDeleted from the resolved resource; throws InvalidOperationException("Resource must be soft-deleted before it can be purged.") if the flag is false.
- For a Dossier: calls DocumentRepository.HardPurgeWithCascade(resourceId) — two-step Cypher purge (descendants first, then the root node).
- For a Document: calls DocumentRepository.DeleteDocumentById(resourceId) (pre-existing method).
- ThalabusInterfaces/Data/Neo4j/INeo4jDocumentRepository.cs — added HardPurgeWithCascade(string dossierId) declaration.
- Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — implemented HardPurgeWithCascade: two sequential ExecuteWriteAsync calls — first MATCH (root:Dossier {id})-[:CONTAINS*]->(child) DETACH DELETE child, then MATCH (root:Dossier {id}) DETACH DELETE root.
Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.
Test results: T1–T4 not yet run against a live Neo4j instance (require integration test environment). Logic verified by code review against the plan's acceptance criteria.
Task 8.10 — Detail
1. Task Overview
Short Description
Implement an async background cron task (ResourcePurgeJob) that periodically queries for all soft-deleted resources whose deletedAt timestamp has exceeded the configured recovery window, and calls PurgeResource on each. The job runs in the background as a hosted service registered in Program.cs.
Objective / Expected Result
- Resources soft-deleted more than RecoveryWindowDays ago are automatically hard-purged without any manual intervention.
- The job runs on a configurable interval (default: once per day).
- The recovery window duration is configurable via appsettings.json.
- No session-type resources are touched (they are purged immediately in Task 7.2 and will not have isDeleted=true at the time the cron runs).
2. Context & Scope
Background / Reason for Change
ACL_Design.md states: "Run the cleanup asynchronously once the recovery window has passed (system cron task)." The recovery window gives privileged users (DOCUMENT SUPER ADMIN) a chance to restore accidentally deleted resources. After it elapses, the cleanup is automatic.
In Scope
- Creating ThalabusCore/Services/ResourcePurgeJob.cs implementing IHostedService (or BackgroundService).
- Querying Neo4j for all Dossier and Document nodes with isDeleted=true AND deletedAt < (now - recoveryWindowDays).
- Calling IResourceService.PurgeResource for each result.
- Reading RecoveryWindowDays and PurgeIntervalHours from IConfiguration (with sensible defaults).
- Logging each purge action and any errors.
Out of Scope
- UI to configure the recovery window — configuration file only.
- Per-resource recovery window overrides — all resources share the global window.
- Purging session resources — those are hard-deleted immediately in Task 7.2.
- Purging type=root or type=user dossiers — they are never soft-deleted; the query will never find them.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/ResourcePurgeJob.cs |
New file |
ThalabusWeb/Program.cs |
Register ResourcePurgeJob as hosted service (Task 8.10.1) |
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs |
New GetResourcesPendingPurge(DateTime olderThan) query method |
appsettings.json (or environment config) |
New section ResourcePurge with RecoveryWindowDays and PurgeIntervalHours |
Dependencies
| Task | Reason |
|---|---|
| Task 8.9 | PurgeResource must be implemented before the cron can call it |
| Task 8.1 | IResourceService must be injectable |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Cron runs while the application is under heavy load | Use a configurable interval; log start/end times; skip a cycle if the previous one is still running (via a SemaphoreSlim) |
| A resource is restored during the purge window (race condition) | PurgeResource checks isDeleted=true before deleting; if restored in the meantime, GetResource will return the node with isDeleted=false and PurgeResource will throw InvalidOperationException — catch and log, continue with next resource |
RecoveryWindowDays = 0 (immediate purge) |
Allowed but flagged as a warning in startup logs — any soft-deleted resource is immediately eligible for purge |
Backward Compatibility Considerations
New hosted service. No existing code is affected. If ResourcePurge section is absent from config, defaults apply (7-day window, 24-hour interval).
4. Implementation Plan
GetResourcesPendingPurge in Neo4jDocumentRepository:
public async Task<IEnumerable<string>> GetResourcesPendingPurge(DateTime olderThan) {
var query = @"
MATCH (n) WHERE (n:Dossier OR n:Document)
AND n.isDeleted = true
AND n.deletedAt < $olderThan
RETURN n.id AS resourceId";
var parameters = new Dictionary<string, object?> {
{ "olderThan", olderThan.ToString("yyyy-MM-ddTHH:mm:ssZ") }
};
// ... execute and collect resourceId strings
}
ResourcePurgeJob.cs:
public class ResourcePurgeJob : BackgroundService {
private readonly IResourceService ResourceService;
private readonly IConfiguration Configuration;
private readonly SemaphoreSlim PurgeLock = new SemaphoreSlim(1, 1);
public ResourcePurgeJob(IResourceService resourceService, IConfiguration configuration) {
ResourceService = resourceService;
Configuration = configuration;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
int intervalHours = Configuration.GetValue("ResourcePurge:PurgeIntervalHours", 24);
while (!stoppingToken.IsCancellationRequested) {
await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
await RunPurgeCycle();
}
}
private async Task RunPurgeCycle() {
if (!await PurgeLock.WaitAsync(0)) return; // skip if already running
try {
int windowDays = Configuration.GetValue("ResourcePurge:RecoveryWindowDays", 7);
var cutoff = DateTime.UtcNow.AddDays(-windowDays);
var ids = await Neo4jDocumentRepository.Singleton!.GetResourcesPendingPurge(cutoff);
foreach (var id in ids) {
try {
await ResourceService.PurgeResource(id);
Log.Info($"[ResourcePurgeJob] Purged resource {id}");
} catch (Exception ex) {
Log.Warning($"[ResourcePurgeJob] Skipped resource {id}: {ex.Message}");
}
}
} finally {
PurgeLock.Release();
}
}
}
appsettings.json section:
"ResourcePurge": {
"RecoveryWindowDays": 7,
"PurgeIntervalHours": 24
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Resource soft-deleted more than RecoveryWindowDays ago |
Purged in the next cron cycle |
| T2 | Resource soft-deleted within RecoveryWindowDays |
Not purged in the cron cycle |
| T3 | Resource restored before cron runs | PurgeResource throws InvalidOperationException; job logs warning and continues |
| T4 | Cron called while previous cycle still running | Second invocation is skipped via SemaphoreSlim |
| T5 | RecoveryWindowDays = 0 |
All soft-deleted resources are immediately eligible for purge |
Acceptance Criteria - T1: resource nodes absent from Neo4j after purge cycle. - T3: job does not crash; continues to purge remaining eligible resources. - T4: no duplicate purge attempts.
Implementation of these tests
Unit tests with mock IResourceService and mock time for T1–T5. Integration test with a short recovery window (1 minute) against test Neo4j for end-to-end verification.
6. Post-Implementation
Date: 2026-05-05
Status: Skipped by user decision. The async purge cron (ResourcePurgeJob) will not be implemented at this stage. Hard-purge remains a manual operation via IResourceService.PurgeResource (Task 8.9). This task can be revisited when automated cleanup becomes a priority.
Task 8.10.1 — Detail
1. Task Overview
Short Description
Register IResourceService (→ ResourceService) as a singleton and ResourcePurgeJob as a hosted service in ThalabusWeb/Program.cs, so both are available to the DI container and the purge job starts automatically with the application.
Objective / Expected Result
- IResourceService is resolvable from the DI container throughout the application.
- ResourcePurgeJob starts when the application starts and is cancelled gracefully on shutdown.
2. Context & Scope
In Scope
- Adding builder.Services.AddSingleton<IResourceService, ResourceService>(); in Program.cs.
- Adding builder.Services.AddHostedService<ResourcePurgeJob>(); in Program.cs.
Out of Scope - Any other DI registration changes. - Configuration validation at startup (recovery window values are read lazily by the job).
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusWeb/Program.cs |
Two new service registrations |
Dependencies
| Task | Reason |
|---|---|
| Task 8.1 | IResourceService must be declared |
| Task 8.10 | ResourcePurgeJob must exist |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
ResourcePurgeJob starts before Neo4j is ready |
The job's first run is delayed by PurgeIntervalHours — Neo4j is typically ready well before then. For safety, wrap the first RunPurgeCycle in a try-catch that logs and continues. |
Backward Compatibility Considerations Additive — two new registrations. No existing registrations are changed.
4. Implementation Plan
In ThalabusWeb/Program.cs, in the singleton services block, add:
builder.Services.AddSingleton<IResourceService, ResourceService>();
builder.Services.AddHostedService<ResourcePurgeJob>();
Also add the required using directives:
using ThalabusCore.Services; // ResourceService, ResourcePurgeJob
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Application starts | No DI resolution exceptions for IResourceService or ResourcePurgeJob |
| T2 | Inject IResourceService in a Blazor page |
Resolves correctly |
Acceptance Criteria
- Application starts without exceptions.
- IResourceService resolves in the DI container.
Implementation of these tests Integration / startup test verifying DI container resolution.
6. Post-Implementation
Date: 2026-05-05
What changed:
- ThalabusWeb/Program.cs — builder.Services.AddSingleton<IResourceService, ResourceService>(); was already present at line 86 (added during a prior session alongside INeo4jDocumentRepository). No code change was required.
- ResourcePurgeJob hosted service registration is omitted because Task 8.10 was skipped.
Build result: No change needed — IResourceService registration was already in place and the project builds clean.
Test results: T1 verified — application starts without DI resolution exceptions for IResourceService. T2 (inject in a Blazor page) not explicitly tested but resolution is guaranteed by the singleton registration.
Task 9 — ACL Entities
| # | Task | Status |
|---|---|---|
| 9.1 | Define Permission type |
implemented |
| 9.2 | Define RoleType enum + Role interface |
implemented |
| 9.3 | Define ACLEntry interface |
implemented |
| 9.4 | Neo4j migration — create OWNS (Group→Resource) relationship |
todo |
Task 9.1 — Detail
1. Task Overview
Short Description
Create a C# Permission enum in a new ThalabusInterfaces/ACL/ namespace, representing the six resource-level permissions defined in ACL_Design.md: Read, Sharing, Delete, Add, Update, and Ownership. This is a pure type declaration with no runtime logic.
Objective / Expected Result
- A file ThalabusInterfaces/ACL/Permission.cs exists with the Permission enum.
- The six values map exactly to the ACL design specification.
- All downstream ACL types (IRole, IAclEntry) and the ACL service (Task 10) compile against this enum.
2. Context & Scope
Background / Reason for Change
ACL_Design.md defines six resource-level permissions (READ, SHARING, DELETE, ADD, UPDATE, OWNERSHIP) that control what operations a group can perform on a dossier or document. The existing INeo4jCapability system is an application-level feature-flag mechanism and serves a completely different purpose — it is not replaced by this enum. The Permission enum is a new, separate concept dedicated to resource ACL.
The permissions are referenced in ACLEntry (the assignment of a role/permissions to a group on a resource), in IRole (fixed permission sets per role type), and in IACLService.CheckAccess (the runtime gate).
In Scope
- Defining the Permission enum with six values in namespace ThalabusInterfaces.ACL.
- Creating the directory ThalabusInterfaces/ACL/ if it does not exist.
- Using C# PascalCase names (Read, Sharing, Delete, Add, Update, Ownership) for the enum values, consistent with C# conventions while mapping directly to the design's uppercase names.
Out of Scope
- Any runtime logic, helper methods, or extension methods on Permission.
- Changes to the existing INeo4jCapability / INeo4jRole system — these coexist unchanged.
- Implementing any service that uses Permission — that is Task 10.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusInterfaces/ACL/Permission.cs |
New file |
Dependencies None — this is a standalone type declaration.
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Name clash with existing System.Security.Permissions namespace |
Use fully qualified namespace ThalabusInterfaces.ACL to avoid any ambiguity |
Delete enum member conflicts with System.Data.DeleteRule or similar via using directives in consuming files |
Fully qualify as ThalabusInterfaces.ACL.Permission.Delete where needed |
Backward Compatibility Considerations New file, no existing callers. No breaking changes.
4. Implementation Plan
Create ThalabusInterfaces/ACL/Permission.cs:
namespace ThalabusInterfaces.ACL {
/// <summary>
/// Resource-level ACL permissions as defined in ACL_Design.md.
/// These are distinct from application-level capabilities (INeo4jCapability).
/// </summary>
public enum Permission {
/// <summary>View / open a document or folder.</summary>
Read,
/// <summary>Manage permissions on a document or folder (sharing).</summary>
Sharing,
/// <summary>Delete a document or folder.</summary>
Delete,
/// <summary>Add documents or sub-folders inside a folder.</summary>
Add,
/// <summary>Rename a document or folder.</summary>
Update,
/// <summary>Transfer ownership of a folder by moving it.</summary>
Ownership
}
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Solution compiles after adding the file | No compile errors |
| T2 | IRole and IAclEntry (Tasks 9.2, 9.3) reference Permission |
No missing-type compile errors |
Acceptance Criteria - File exists; solution compiles cleanly.
Implementation of these tests Compile-time verification at Tasks 9.2 and 9.3.
6. Post-Implementation
Placeholder — to be populated after implementation.
What changed:
Permission.cscreatedTest results: outcomes of T1–T2
Task 9.2 — Detail
1. Task Overview
Short Description
Create ThalabusInterfaces/ACL/IRole.cs containing the RoleType enum, the IRole interface, and the companion static AclRoles class that exposes the five pre-defined role instances with their canonical permission sets as specified in ACL_Design.md. Keeping RoleType and IRole in the same file avoids a one-type-per-file split for two types that are tightly coupled by design.
Objective / Expected Result
- RoleType enum with five values exists in ThalabusInterfaces/ACL/IRole.cs.
- IRole is declared with Name (RoleType) and Permissions (IReadOnlyList<Permission>).
- AclRoles exposes five static readonly instances: SuperAdmin, Owner, Admin, Manager, Viewer, each with the correct permission set.
- Callers can look up the canonical role for a RoleType value via AclRoles.For(RoleType roleType).
- IAclEntry.Role (Task 9.3) and the ACL service (Task 10) compile against this file.
2. Context & Scope
Background / Reason for Change
Roles in the ACL design are not ad-hoc — they are a fixed, closed set with pre-defined permissions. RoleType is solely an input discriminator for IRole; it has no independent consumers. Co-locating both in IRole.cs reflects this coupling and removes unnecessary file navigation. The existing INeo4jRole is a Neo4j node whose name is a free-form user-defined string — a completely different concept (application feature access); the ACL RoleType is a closed system-defined set and must not be conflated with it.
Roles from ACL_Design.md:
| Role | Permissions |
|---|---|
| SUPER_ADMIN | Read, Sharing, Delete, Add, Update, Ownership |
| OWNER | Read, Sharing, Delete, Add, Update, Ownership |
| ADMIN | Read, Sharing, Delete, Add, Update |
| MANAGER | Read, Sharing, Add, Update |
| VIEWER | Read |
In Scope
- Defining RoleType enum (5 values, PascalCase) in ThalabusInterfaces/ACL/IRole.cs.
- Defining IRole interface in the same file.
- Defining AclRoles static class in the same file with five pre-built instances and a For(RoleType) lookup.
- Internal AclRole sealed record implementing IRole.
Out of Scope
- A standalone RoleType.cs file — RoleType lives in IRole.cs only.
- Allowing callers to create custom roles — roles are a closed system-defined set.
- Storing IRole as a Neo4j node — roles are never persisted in the graph. SuperAdmin identity is determined by capability traversal ((Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'})), not by any property or ACL Role abstraction.
- Any changes to the existing INeo4jRole system.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusInterfaces/ACL/IRole.cs |
New file (RoleType enum + IRole interface + AclRoles class + AclRole record) |
Dependencies
| Task | Reason |
|---|---|
| Task 9.1 | Permission enum must be declared |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
AclRoles.For called with an unrecognised RoleType value |
Throw ArgumentOutOfRangeException — no fallback; the caller must pass a valid enum value |
Backward Compatibility Considerations New file. No existing callers.
4. Implementation Plan
Create ThalabusInterfaces/ACL/IRole.cs:
namespace ThalabusInterfaces.ACL {
/// <summary>
/// Fixed ACL role types as defined in ACL_Design.md.
/// Each role has a pre-defined set of Permissions.
/// These are distinct from application-level Neo4j roles (INeo4jRole).
/// Serialised as a string on Neo4j [:GRANT] relationship properties.
/// </summary>
public enum RoleType {
/// <summary>Full access to all resources in the tenant; bypasses isProtected.</summary>
SuperAdmin,
/// <summary>Full control over owned resources.</summary>
Owner,
/// <summary>Read, Sharing, Delete, Add, Update — no Ownership transfer.</summary>
Admin,
/// <summary>Read, Sharing, Add, Update — no Delete or Ownership.</summary>
Manager,
/// <summary>Read only.</summary>
Viewer
}
public interface IRole {
RoleType Name { get; }
IReadOnlyList<Permission> Permissions { get; }
}
/// <summary>
/// Pre-defined canonical roles with their permission sets.
/// Roles are a closed system-defined set and cannot be customised.
/// </summary>
public static class AclRoles {
private sealed record AclRole(RoleType Name, IReadOnlyList<Permission> Permissions) : IRole;
public static readonly IRole SuperAdmin = new AclRole(
RoleType.SuperAdmin,
new[] { Permission.Read, Permission.Sharing, Permission.Delete,
Permission.Add, Permission.Update, Permission.Ownership });
public static readonly IRole Owner = new AclRole(
RoleType.Owner,
new[] { Permission.Read, Permission.Sharing, Permission.Delete,
Permission.Add, Permission.Update, Permission.Ownership });
public static readonly IRole Admin = new AclRole(
RoleType.Admin,
new[] { Permission.Read, Permission.Sharing, Permission.Delete,
Permission.Add, Permission.Update });
public static readonly IRole Manager = new AclRole(
RoleType.Manager,
new[] { Permission.Read, Permission.Sharing,
Permission.Add, Permission.Update });
public static readonly IRole Viewer = new AclRole(
RoleType.Viewer,
new[] { Permission.Read });
public static IRole For(RoleType roleType) => roleType switch {
RoleType.SuperAdmin => SuperAdmin,
RoleType.Owner => Owner,
RoleType.Admin => Admin,
RoleType.Manager => Manager,
RoleType.Viewer => Viewer,
_ => throw new ArgumentOutOfRangeException(nameof(roleType), roleType, "Unknown RoleType")
};
}
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Enum.Parse<RoleType>("SuperAdmin", ignoreCase: true) |
Returns RoleType.SuperAdmin |
| T2 | RoleType.Viewer.ToString() |
Returns "Viewer" |
| T3 | AclRoles.Owner.Permissions |
Contains all 6 permissions |
| T4 | AclRoles.Viewer.Permissions |
Contains only Permission.Read |
| T5 | AclRoles.Admin.Permissions |
Does NOT contain Permission.Ownership |
| T6 | AclRoles.Manager.Permissions |
Does NOT contain Permission.Delete or Permission.Ownership |
| T7 | AclRoles.For(RoleType.Viewer) |
Returns the same instance as AclRoles.Viewer |
| T8 | AclRoles.For((RoleType)999) |
Throws ArgumentOutOfRangeException |
Acceptance Criteria
- T1–T8 pass.
- AclRoles.SuperAdmin.Permissions == AclRoles.Owner.Permissions (same set, different role name — design intent).
Implementation of these tests
Unit tests in ThalabusUnitTests/ACL/AclRolesTests.cs (covers both RoleType serialisation and AclRoles permission sets).
6. Post-Implementation
Placeholder — to be populated after implementation.
What changed:
IRole.cscreated withRoleTypeenum,IRoleinterface, andAclRolesstatic classTest results: outcomes of T1–T8
Task 9.3 — Detail
1. Task Overview
Short Description
Create an IAclEntry interface in ThalabusInterfaces/ACL/ representing a single ACL grant: the assignment of a Role (with optional permission overrides) to a Group (identified by GroupId string) on a specific resource. This is the in-memory contract that the ACL service reads when evaluating access. A companion AclEntry record provides the default implementation.
Objective / Expected Result
- IAclEntry is declared with ResourceId, GroupId, Role, PermissionOverrides?, and UpdatedAt.
- An AclEntry record implementing IAclEntry is available for construction.
- The ACL service (Task 10) uses IEnumerable<IAclEntry> as the return type of GetAcl(resourceId).
2. Context & Scope
Background / Reason for Change
ACL_Technical_Interfaces.md defines the following contract (C# equivalent):
public interface IAclEntry {
string ResourceId { get; }
string GroupId { get; } // always a Group ID — users are never granted directly
IRole Role { get; }
IReadOnlyList<Permission>? PermissionOverrides { get; } // null = use role defaults
DateTime UpdatedAt { get; }
}
Role is a C# abstraction only — it is never stored in the Neo4j graph. When an IAclEntry with Role = Manager is persisted, the ACL service creates one [:GRANT:<PERMISSION>] typed relationship per effective permission (e.g. [:GRANT:READ], [:GRANT:SHARING], [:GRANT:ADD], [:GRANT:UPDATE]). When read back, the permission set is reconstructed from the typed relationships and matched to the closest canonical role via ResolveRoleFromPermissions. The permissions override allows granting a group more or fewer permissions than the standard role provides.
In Scope
- Defining IAclEntry in ThalabusInterfaces/ACL/IAclEntry.cs.
- Defining AclEntry record (implementing IAclEntry) for construction by the repository layer.
- PermissionOverrides is nullable — null means "use the role's canonical permissions"; a non-null list replaces them entirely.
- Documenting the effective permissions logic: PermissionOverrides ?? Role.Permissions.
Out of Scope
- Neo4j persistence of IAclEntry (reading/writing [:GRANT] relationships) — covered by Task 10.7 (GetAcl, SetAcl, AddPermission, RemovePermission). No prior migration script is required; [:GRANT] relationships are created at runtime.
- Validation that the group exists in Neo4j — performed at write time by the ACL service.
- Multi-resource or wildcard ACL entries — not supported in this design.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusInterfaces/ACL/IAclEntry.cs |
New file (IAclEntry interface + AclEntry record) |
Dependencies
| Task | Reason |
|---|---|
| Task 9.1 | Permission enum must be declared |
| Task 9.2 | RoleType enum and IRole must be declared |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
PermissionOverrides is an empty list [] vs null |
An empty list means "no permissions granted" (effectively deny); null means "use role defaults". Both are valid; the ACL service must distinguish them. |
One group has multiple [:GRANT:<PERMISSION>] relationships to the same resource |
This is expected — one relationship per permission (e.g. [:GRANT:READ], [:GRANT:SHARING]). AddPermission uses APOC apoc.merge.relationship to ensure idempotency: calling it twice for the same (group, resource, permission) updates the existing relationship in place. |
Backward Compatibility Considerations New file. No existing callers.
4. Implementation Plan
Create ThalabusInterfaces/ACL/IAclEntry.cs:
namespace ThalabusInterfaces.ACL {
/// <summary>
/// A single ACL entry — assigns a Role (with optional permission overrides) to a Group on a specific resource.
/// Role is a C# abstraction only: in Neo4j, each effective permission is stored as a separate typed relationship
/// (e.g. [:GRANT:READ], [:GRANT:SHARING]) carrying only grantedAt and grantedBy as metadata.
/// </summary>
public interface IAclEntry {
/// <summary>The ID of the resource (Dossier or Document) this entry applies to.</summary>
string ResourceId { get; }
/// <summary>
/// The ID of the Group that is granted this entry.
/// Always a Group ID — users are never granted directly; they act through group membership.
/// </summary>
string GroupId { get; }
/// <summary>The role assigned to the group on this resource.</summary>
IRole Role { get; }
/// <summary>
/// Optional explicit permission list that overrides the role's canonical permissions.
/// Null means the role's default permissions apply.
/// An empty list means no permissions are granted (effectively deny).
/// </summary>
IReadOnlyList<Permission>? PermissionOverrides { get; }
/// <summary>Effective permissions: overrides if present, otherwise role's canonical set.</summary>
IReadOnlyList<Permission> EffectivePermissions =>
PermissionOverrides ?? Role.Permissions;
DateTime UpdatedAt { get; }
/// <summary>
/// The ID of the effective user (user or system principal) who created this grant.
/// Corresponds to the grantedBy property on the [:GRANT] relationship in Neo4j
/// as specified in ACL_Design.md.
/// </summary>
string? GrantedBy { get; }
}
/// <summary>Default implementation of IAclEntry for use by the repository layer.</summary>
public sealed record AclEntry(
string ResourceId,
string GroupId,
IRole Role,
IReadOnlyList<Permission>? PermissionOverrides,
DateTime UpdatedAt,
string? GrantedBy = null
) : IAclEntry;
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | AclEntry with PermissionOverrides = null |
EffectivePermissions == Role.Permissions |
| T2 | AclEntry with PermissionOverrides = [Permission.Read] |
EffectivePermissions == [Permission.Read] (overrides role) |
| T3 | AclEntry with PermissionOverrides = [] (empty) |
EffectivePermissions is empty — effectively no access |
| T4 | Two AclEntry instances with same values |
Record equality returns true |
Acceptance Criteria - T1–T4 pass; solution compiles.
Implementation of these tests
Unit tests in ThalabusUnitTests/ACL/AclEntryTests.cs.
6. Post-Implementation
Placeholder — to be populated after implementation.
What changed:
IAclEntry.cscreated withAclEntryrecordTest results: outcomes of T1–T4
Task 9.4 — Detail
1. Task Overview
Short Description
Write and execute a Cypher migration script that creates (Group)-[:OWNS]->(Dossier) edges in Neo4j for all existing user-provisioned dossiers that do not yet have an [:OWNS] edge. Ownership for user dossiers is derived by traversing (u:User)-[:MEMBER_OF]->(g:Group {isUserGroup:true})-[:OWNS]->(d:Dossier {type:'user'}) edges provisioned by Task 4. The ACL service (Task 10) will traverse the resulting edges when evaluating ownership.
Objective / Expected Result
- For every user Dossier (type = user) linked to a Group via (u:User)-[:MEMBER_OF]->(g:Group {isUserGroup:true}), a (g:Group)-[:OWNS]->(dossier) edge exists.
- Dossiers/documents without derivable ownership remain without an [:OWNS] edge — they are treated as "unowned" (accessible only by SUPER_ADMIN).
- The migration is idempotent.
- Script stored at Neo4jORM/migrations/008_acl_owns_relationship.cypher.
2. Context & Scope
Background / Reason for Change
The [:OWNS] relationship was freed up for (Group)-[:OWNS]->(Resource) semantics in Task 1. Tasks 4.1–4.2 provision user groups and dossiers, creating [:OWNS] edges at provisioning time. This migration backfills [:OWNS] edges for user dossiers that were provisioned before Task 4's implementation — deriving ownership by traversing (u:User)-[:MEMBER_OF]->(g:Group {isUserGroup:true}) to the existing dossier linked under that group.
In Scope
- Creating (g:Group {isUserGroup:true})-[:OWNS]->(d:Dossier {type:'user'}) for all Group/Dossier pairs reachable via (u:User)-[:MEMBER_OF]->(g) where the [:OWNS] edge does not yet exist.
- Creating a Neo4j index on [:OWNS] for efficient ownership lookups.
- Surfacing resources where a Group has a MEMBER_OF from a user but no [:OWNS]->(Dossier {type:'user'}) edge (data quality check).
Out of Scope
- Assigning ownership to non-user dossiers or documents — that requires user/admin decisions and is out of scope.
- Handling the (Group)-[:OWNS]->(Group) or (Group)-[:OWNS]->(User) variants — [:OWNS] is exclusively between a Group and a Dossier/Document resource.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
| Neo4j live database | [:OWNS] edges created for resources identified via (User)-[:MEMBER_OF]->(Group) traversal; index on [:OWNS] |
Dependencies
| Task | Reason |
|---|---|
| Task 1 | [:OWNS] must have been freed from its old semantics (Dossier→Document, Session→Dossier) before this migration can safely use the relationship name for Group→Resource |
| Task 3.3 | Group nodes must exist in Neo4j before their IDs can be used as relationship endpoints |
| Task 4.4 | All users must have MEMBER_OF edges to their hidden Group (by backfill or fresh provisioning) |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
A resource already has an [:OWNS] edge from a previous partial run |
MERGE is idempotent — no duplicate edges |
| Large number of resources causing timeout | Use CALL { ... } IN TRANSACTIONS OF 500 ROWS |
Backward Compatibility Considerations
Purely additive. No existing queries use (Group)-[:OWNS]->(Resource) — the relationship is new.
4. Implementation Plan
Migration script — store at Neo4jORM/migrations/008_acl_owns_relationship.cypher:
// Step 1 — Baseline counts
MATCH (u:User)-[:MEMBER_OF]->(g:Group {isUserGroup: true})
RETURN count(DISTINCT u) AS users_with_provisioned_groups;
MATCH (g:Group)-[:OWNS]->(r) RETURN count(*) AS existing_owns_edges;
// Step 2 — Orphan check: Groups with MEMBER_OF but no OWNS→Dossier
MATCH (u:User)-[:MEMBER_OF]->(g:Group {isUserGroup: true})
WHERE NOT EXISTS { MATCH (g)-[:OWNS]->(:Dossier {type: 'user'}) }
RETURN u.id AS user_id, g.id AS group_id;
// Investigate any rows returned before proceeding.
// Step 3 — Create OWNS edges for user-provisioned dossiers
CALL {
MATCH (u:User)-[:MEMBER_OF]->(g:Group {isUserGroup: true})
MATCH (g)-[:OWNS]->(d:Dossier {type: 'user'})
WHERE NOT EXISTS { MATCH (g)-[:OWNS]->(d) }
MERGE (g)-[:OWNS]->(d)
} IN TRANSACTIONS OF 500 ROWS;
// Step 4 — Create index for efficient ownership lookups
CREATE INDEX acl_owns_group IF NOT EXISTS
FOR ()-[r:OWNS]-()
ON (r);
// Note: Neo4j 5.x supports relationship indexes; adjust syntax for Neo4j 4.x if needed.
// Step 5 — Verify
MATCH (g:Group)-[:OWNS]->(r) RETURN count(*) AS total_owns_edges;
// Dossiers with type=user that still have no [:OWNS] edge
MATCH (d:Dossier {type: 'user'}) WHERE NOT EXISTS { MATCH (g:Group)-[:OWNS]->(d) }
RETURN count(d) AS user_dossiers_still_missing_owns; // Should be 0 (excluding acknowledged orphans)
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | User with MEMBER_OF edge to Group and Group's dossier already provisioned |
[:OWNS] edge confirmed or created |
| T2 | User dossier already has [:OWNS] edge |
MERGE is idempotent — no duplicate edge |
| T3 | Run migration twice | Idempotent — no duplicate edges |
| T4 | ACL service can traverse (Group)-[:OWNS]->(Resource) after migration |
Ownership query returns correct group |
Acceptance Criteria
- Step 5 verification query: user_dossiers_still_missing_owns = 0 (excluding acknowledged orphans from Step 2).
- Step 2 orphan queries reviewed and either return 0 rows or each orphan is documented.
Implementation of these tests
Run Step 2 and 6 verification queries as part of deployment checklist. Integration test in ThalabusUnitTests covering T1–T5 against a test Neo4j instance.
6. Post-Implementation
Placeholder — to be populated after implementation.
What changed: edge counts before/after; orphan report
Test results: outcomes of T1–T6
Task 10 — ACL Service
| # | Task | Status |
|---|---|---|
| 10.1 | Define IAclService interface |
implemented |
| 10.2 | Implement CheckAccess |
implemented |
| 10.3 | Implement GetEffectivePermissions |
implemented |
| 10.4 | Implement permission inheritance traversal (child inherits from parent via CONTAINS) |
implemented |
| 10.5 | Implement isProtected inheritance-break logic |
implemented |
| 10.6 | Implement SUPER_ADMIN bypass logic | implemented |
| 10.7 | Implement GetAcl / SetAcl / AddPermission / RemovePermission |
implemented |
| 10.7.1 | Register IAclService in Program.cs |
implemented |
Task 10.1 — Detail
1. Task Overview
Short Description
Create the IAclService interface in ThalabusInterfaces/Services/ declaring the full ACL service contract: access checking, effective permission resolution, and ACL entry management.
Objective / Expected Result
- IAclService exists with six async methods matching ACL_Technical_Interfaces.md.
- All methods are Task-returning so callers can await the Neo4j I/O that backs each operation.
- AclService (Tasks 10.2–10.7) and any test mocks implement this interface.
2. Context & Scope
Background / Reason for Change The ACL service is the single gateway through which all access decisions flow. Defining the interface first lets all other components (Resource Service, Blazor UI, test doubles) depend on the abstraction rather than the concrete implementation. It also establishes the method signatures before the more complex traversal logic is written.
In Scope
- Declaring the six methods below in ThalabusInterfaces/Services/IAclService.cs.
- Using async-compatible return types (Task<bool>, Task<IReadOnlyList<Permission>>, etc.) throughout.
- XML documentation comments for each method.
Out of Scope
- Any implementation — covered by Tasks 10.2–10.7.
- Defining the ACL domain types (Permission, IAclEntry) — these are already defined in Tasks 9.1–9.3.
- ACL enforcement at existing call sites — covered by Task 12.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusInterfaces/Services/IAclService.cs |
New file |
Dependencies
| Task | Reason |
|---|---|
| Task 9.1 | Permission enum must be declared |
| Task 9.3 | IAclEntry must be declared |
Risks & Edge Cases None — pure interface declaration.
Backward Compatibility Considerations New file; no existing callers. No breaking changes.
4. Implementation Plan
Create ThalabusInterfaces/Services/IAclService.cs:
using ThalabusInterfaces.ACL;
namespace ThalabusInterfaces.Services {
/// <summary>
/// Gate for all resource-level access decisions.
/// All operations that read or mutate a resource must call CheckAccess before proceeding.
/// </summary>
public interface IAclService {
/// <summary>
/// Returns true if the user holds the specified permission on the resource.
/// Evaluates inherited permissions (via CONTAINS ancestry) and SUPER_ADMIN bypass.
/// </summary>
Task<bool> CheckAccess(string userId, string resourceId, Permission permission);
/// <summary>
/// Returns the full set of permissions the user holds on the resource
/// after evaluating inheritance, isProtected barriers, and SUPER_ADMIN bypass.
/// </summary>
Task<IReadOnlyList<Permission>> GetEffectivePermissions(string userId, string resourceId);
/// <summary>
/// Returns all ACL entries (GRANT relationships) directly assigned to the resource.
/// Does not include inherited entries from ancestor resources.
/// </summary>
Task<IEnumerable<IAclEntry>> GetAcl(string resourceId);
/// <summary>
/// Replaces ALL ACL entries on the resource with the provided set.
/// Existing GRANT relationships are deleted and re-created atomically.
/// Requires the caller to have already checked SHARING permission.
/// </summary>
Task SetAcl(string resourceId, IEnumerable<IAclEntry> entries, string effectiveUserId);
/// <summary>
/// Adds or updates a single GRANT relationship for the given entry's subject on the resource.
/// Uses MERGE — if a grant for the same (group, resource) already exists, it is updated.
/// Requires the caller to have already checked SHARING permission.
/// </summary>
Task AddPermission(IAclEntry entry, string effectiveUserId);
/// <summary>
/// Removes the GRANT relationship between the specified group and the resource.
/// No-op if no grant exists for the group.
/// Requires the caller to have already checked SHARING permission.
/// </summary>
Task RemovePermission(string resourceId, string groupId, string effectiveUserId);
}
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Solution compiles with all 9.x types in place | No compile errors |
| T2 | A mock IAclService can be registered in DI for unit tests |
No DI or reflection errors |
Acceptance Criteria - File exists; solution compiles cleanly.
Implementation of these tests Compile-time only at this task. Full behavioural tests added in Tasks 10.2–10.7.
6. Post-Implementation
Placeholder — to be populated after implementation.
What changed:
IAclService.cscreatedTest results: compile-time verification
Task 10.2 — Detail
1. Task Overview
Short Description
Create AclService in ThalabusCore/Services/ and implement CheckAccess(userId, resourceId, permission) — the primary ACL gate. The method resolves the user's effective permissions on the given resource (delegating to GetEffectivePermissions) and returns whether the requested permission is present.
Objective / Expected Result
- CheckAccess returns true if the user holds the requested permission on the resource (directly or by inheritance or via SUPER_ADMIN bypass); false otherwise.
- SUPER_ADMIN users always get true.
- Non-existent users or resources return false (no exception).
2. Context & Scope
Background / Reason for Change
CheckAccess is the single boolean gate that all resource operations will call (Task 12). It must be fast (index-backed Neo4j queries), deterministic, and cover the three permission resolution paths: direct grant, inherited grant, and SUPER_ADMIN bypass.
In Scope
- Declaring the AclService class implementing IAclService.
- Constructor taking IServiceProvider (to resolve INeo4jDataAccess) and IGroupService.
- CheckAccess implemented as: get effective permissions → return permissions.Contains(permission).
- The class file at ThalabusCore/Services/AclService.cs (shared by Tasks 10.2–10.7).
Out of Scope
- Implementing GetEffectivePermissions logic (Task 10.3).
- Implementing SUPER_ADMIN bypass detection (Task 10.6) — both are called from CheckAccess but implemented in their own tasks.
- Stub the unimplemented methods with throw new NotImplementedException() at this stage.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/AclService.cs |
New file; CheckAccess implemented; all other methods stubbed |
Dependencies
| Task | Reason |
|---|---|
| Task 10.1 | IAclService interface must exist |
| Task 9.1 | Permission enum |
| Task 3.2 | IGroupService.GetGroups(userId) used to get user's groups |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
userId maps to no groups in Neo4j |
GetGroups returns empty; effective permissions is empty; CheckAccess returns false |
resourceId not found |
GetAcl returns empty set; effective permissions is empty; CheckAccess returns false |
Backward Compatibility Considerations New file. No existing code is modified.
4. Implementation Plan
Create ThalabusCore/Services/AclService.cs with the following initial structure:
using ThalabusInterfaces.Services;
using ThalabusInterfaces.ACL;
using ThalabusInterfaces.Data.Neo4j.DataAccess;
using Microsoft.Extensions.DependencyInjection;
namespace ThalabusCore.Services {
public class AclService : IAclService {
private readonly IServiceProvider ServiceProvider;
private readonly IGroupService GroupService;
public AclService(IServiceProvider serviceProvider, IGroupService groupService) {
ServiceProvider = serviceProvider;
GroupService = groupService;
}
// Task 10.2
public async Task<bool> CheckAccess(string userId, string resourceId, Permission permission) {
var effectivePermissions = await GetEffectivePermissions(userId, resourceId);
return effectivePermissions.Contains(permission);
}
// Task 10.3 — implemented in Task 10.3
public Task<IReadOnlyList<Permission>> GetEffectivePermissions(string userId, string resourceId)
=> throw new NotImplementedException();
// Task 10.7 — implemented in Task 10.7
public Task<IEnumerable<IAclEntry>> GetAcl(string resourceId)
=> throw new NotImplementedException();
public Task SetAcl(string resourceId, IEnumerable<IAclEntry> entries, string effectiveUserId)
=> throw new NotImplementedException();
public Task AddPermission(IAclEntry entry, string effectiveUserId)
=> throw new NotImplementedException();
public Task RemovePermission(string resourceId, string groupId, string effectiveUserId)
=> throw new NotImplementedException();
}
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | CheckAccess with mocked GetEffectivePermissions returning [Read], requesting Read |
Returns true |
| T2 | CheckAccess with mocked GetEffectivePermissions returning [Read], requesting Delete |
Returns false |
| T3 | CheckAccess with mocked GetEffectivePermissions returning [] (empty) |
Returns false for any permission |
Acceptance Criteria
- T1–T3 pass.
- CheckAccess never throws for valid inputs.
Implementation of these tests
Unit tests in ThalabusUnitTests/ACL/AclServiceCheckAccessTests.cs. Mock IGroupService and IAclService.GetEffectivePermissions (partial mock or separate test helper).
6. Post-Implementation
Date: 2026-05-05
What changed:
- ThalabusCore/Services/AclService.cs — new file created.
- AclService implements IAclService; exposes public static IAclService? Singleton.
- Constructor injects IServiceProvider (for future Neo4j access) and IGroupService.
- CheckAccess implemented: delegates to GetEffectivePermissions and returns permissions.Contains(permission).
- All other interface methods (GetEffectivePermissions, GetAcl, SetAcl, AddPermission, RemovePermission) stubbed with throw new NotImplementedException("Implemented in Task 10.x") as placeholders for Tasks 10.3 and 10.7.
- ThalabusWeb/Program.cs — added builder.Services.AddSingleton<IAclService, AclService>(); after IResourceService registration (must follow IGroupService).
Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.
Test results: T1–T3 not yet run — CheckAccess delegates to GetEffectivePermissions which is not yet implemented (Task 10.3). Unit tests against a mock IAclService will be written once Task 10.3 is complete.
Task 10.3 — Detail
1. Task Overview
Short Description
Implement GetEffectivePermissions(userId, resourceId) in AclService. This method collects all permissions the user holds on the given resource by: (1) resolving the user's groups, (2) applying SUPER_ADMIN bypass, (3) fetching the resource's own direct grants, (4) walking the [:CONTAINS] ancestor chain to collect inherited grants (with isProtected barriers and short-circuit), and (5) returning the union of all collected permissions.
Objective / Expected Result
- Returns IReadOnlyList<Permission> containing every permission the user holds on the resource.
- SUPER_ADMIN users receive all six permissions regardless of actual grants.
- Protected resources use only their own direct grants (no ancestor traversal).
- Non-protected resources inherit from ancestors up to (and including) the first protected ancestor.
2. Context & Scope
Background / Reason for Change
GetEffectivePermissions is the core of the ACL engine. It encapsulates the full resolution logic described in ACL_Design.md and is called by both CheckAccess (for a binary yes/no) and any permission-enumeration UI that needs to show which actions are available to the user.
The resolution order is:
1. SUPER_ADMIN bypass → return all permissions immediately (Task 10.6)
2. Fetch direct grants on the resource for any of the user's groups
3. If resource is NOT isProtected: walk ancestor chain (Task 10.4), stopping at the first protected ancestor (inclusive) (Task 10.5)
4. Union all collected permission sets → deduplicate → return
In Scope
- Implementing GetEffectivePermissions in AclService.
- Delegating to IsTenantAdmin(groupIds) (Task 10.6) as a first check.
- Delegating to GetDirectGrants(resourceId, groupIds) (internal helper) for step 2.
- Delegating to GetInheritedPermissions(resourceId, groupIds) (Task 10.4) for step 3.
- Checking isProtected on the resource node before invoking ancestor traversal (Task 10.5).
Out of Scope
- The ancestor traversal itself — implemented in Task 10.4.
- isProtected barrier logic in the traversal — implemented in Task 10.5.
- SUPER_ADMIN detection — implemented in Task 10.6.
- Caching effective permissions — not in scope for this plan; can be added later.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/AclService.cs |
Replace GetEffectivePermissions stub |
Dependencies
| Task | Reason |
|---|---|
| Task 9.3 | IAclEntry.EffectivePermissions provides the per-entry computed set |
| Task 10.6 | IsTenantAdmin(groupIds) must be callable |
| Task 10.4 | GetInheritedPermissions must be callable |
| Task 10.5 | IsResourceProtected(resourceId) must be callable |
| Task 10.7 | GetDirectGrants(resourceId, groupIds) uses the same Cypher pattern as GetAcl |
| Task 3.2 | IGroupService.GetGroups(userId) |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
User belongs to many groups — Cypher IN list becomes large |
Neo4j handles IN efficiently with indexed Group IDs; no pagination needed at this scale |
| Resource has no direct grants and no protected ancestor — full tree walk | Bounded by tree depth; acceptable for typical dossier structures |
Permission duplicates in the union (same permission from two different groups) |
Use Distinct() before returning |
Backward Compatibility Considerations
Changes are within the new AclService class; no existing code is modified.
4. Implementation Plan
Replace the GetEffectivePermissions stub in AclService with:
public async Task<IReadOnlyList<Permission>> GetEffectivePermissions(
string userId, string resourceId) {
// Step 1: resolve user's groups
var groups = (await GroupService.GetGroups(userId)).ToList();
var groupIds = groups.Select(g => g.Id).ToList();
// Step 2: SUPER_ADMIN bypass (Task 10.6)
if (await IsTenantAdmin(groupIds))
return AclRoles.SuperAdmin.Permissions;
// Step 3: direct grants on this resource for any of the user's groups
var directEntries = await GetDirectGrants(resourceId, groupIds);
var permissions = directEntries
.SelectMany(e => e.EffectivePermissions)
.ToHashSet();
// Step 4: inherited grants (skipped if resource is protected — Task 10.5)
var isProtected = await IsResourceProtected(resourceId);
if (!isProtected) {
var inherited = await GetInheritedPermissions(resourceId, groupIds);
foreach (var p in inherited) permissions.Add(p);
}
return permissions.ToList();
}
Add private helper GetDirectGrants(string resourceId, IList<string> groupIds):
private async Task<IEnumerable<IAclEntry>> GetDirectGrants(
string resourceId, IList<string> groupIds) {
var neo4j = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
var query = @"
MATCH (g:Group)-[grant]->(r {id: $resourceId})
WHERE g.id IN $groupIds
AND type(grant) STARTS WITH 'GRANT:'
RETURN g.id AS groupId,
type(grant) AS grantType,
grant.grantedAt AS grantedAt,
grant.grantedBy AS grantedBy";
var parameters = new Dictionary<string, object?> {
{ "resourceId", resourceId },
{ "groupIds", groupIds }
};
var records = await neo4j.ExecuteReadVectorAsync(query, parameters);
return MapToAclEntries(records, resourceId);
}
private IEnumerable<IAclEntry> MapToAclEntries(
IEnumerable<IDictionary<string, object?>> records, string resourceId) {
// Group rows by GroupId — multiple rows per group are expected (one per [:GRANT:<PERMISSION>] relationship).
return records
.GroupBy(r => r["groupId"]?.ToString() ?? string.Empty)
.Select(g => {
var permissions = g
.Select(r => r["grantType"]?.ToString()?.Replace("GRANT:", string.Empty))
.Where(p => !string.IsNullOrEmpty(p))
.Select(p => Enum.Parse<Permission>(p!, ignoreCase: true))
.ToList();
// Use the most recent grantedAt across all relationships as UpdatedAt.
var updatedAt = g
.Select(r => r["grantedAt"] is string s
? DateTime.Parse(s, null, System.Globalization.DateTimeStyles.RoundtripKind)
: DateTime.MinValue)
.Max();
var grantedBy = g.First().ContainsKey("grantedBy")
? g.First()["grantedBy"]?.ToString()
: null;
// Infer canonical role from the permission set; fall back to PermissionOverrides
// if no canonical role matches exactly.
var (role, overrides) = ResolveRoleFromPermissions(permissions);
return (IAclEntry)new AclEntry(
ResourceId: resourceId,
GroupId: g.Key,
Role: role,
PermissionOverrides: overrides,
UpdatedAt: updatedAt,
GrantedBy: grantedBy
);
});
}
/// <summary>
/// Attempts to match a permission set to a canonical ACL role.
/// If the set matches a role exactly, returns (role, null) — no overrides needed.
/// Otherwise returns (AclRoles.Viewer, permissions) so EffectivePermissions remains correct.
/// </summary>
private static (IRole Role, IReadOnlyList<Permission>? Overrides)
ResolveRoleFromPermissions(IReadOnlyList<Permission> permissions) {
foreach (var candidate in new[] {
AclRoles.SuperAdmin, AclRoles.Owner, AclRoles.Admin, AclRoles.Manager, AclRoles.Viewer })
{
if (candidate.Permissions.Count == permissions.Count
&& candidate.Permissions.All(p => permissions.Contains(p)))
return (candidate, null);
}
// Custom permission set — use Viewer as the base role; PermissionOverrides carries the effective set.
return (AclRoles.Viewer, permissions);
}
Add private helper IsResourceProtected(string resourceId):
private async Task<bool> IsResourceProtected(string resourceId) {
var neo4j = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
var query = @"
MATCH (r {id: $resourceId})
RETURN coalesce(r.isProtected, false) AS isProtected";
var parameters = new Dictionary<string, object?> { { "resourceId", resourceId } };
var result = await neo4j.ExecuteReadScalarAsync<bool>(query, parameters);
return result;
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | User in group with Owner grant on resource (not protected) |
All 6 permissions returned |
| T2 | User in group with Viewer grant on resource |
Only Read returned |
| T3 | User in group with Manager grant + resource is protected |
Only Manager permissions (no ancestors consulted) |
| T4 | User in group with Viewer grant on resource; parent has Admin grant for same group |
Union: Read (direct) + Admin permissions = [Read, Sharing, Delete, Add, Update] |
| T5 | User has no groups | Returns [] |
| T6 | Resource does not exist | Returns [] |
| T7 | SUPER_ADMIN user | Returns all 6 permissions regardless of resource state |
Acceptance Criteria
- T1–T7 pass.
- Distinct() ensures no duplicate permissions in the return value.
Implementation of these tests
Unit tests in ThalabusUnitTests/ACL/AclServiceEffectivePermissionsTests.cs. Mock IGroupService and INeo4jDataAccess to inject controlled responses.
6. Post-Implementation
Date: 2026-05-05
What changed:
- ThalabusCore/Services/AclService.cs — GetEffectivePermissions fully implemented plus all private helpers required by Task 10.3:
- GetEffectivePermissions: resolves user groups, delegates to IsTenantAdmin (stub), collects GetDirectGrants, checks IsResourceProtected, conditionally calls GetInheritedPermissions (stub), returns deduplicated union as IReadOnlyList<Permission>.
- GetDirectGrants: Cypher MATCH (g:Group)-[grant]->(r {id}) WHERE g.id IN $groupIds AND type(grant) STARTS WITH 'GRANT:' — returns one row per [:GRANT:<PERMISSION>] relationship; rows are grouped by groupId and mapped via MapToAclEntries.
- MapToAclEntries: groups rows by groupId, parses grantType suffix into Permission enum values, picks the latest grantedAt as UpdatedAt, calls ResolveRoleFromPermissions to pick the canonical role (or fall back to Viewer + PermissionOverrides).
- ResolveRoleFromPermissions: matches permission set against all five canonical roles in descending privilege order; returns (role, null) on exact match, (Viewer, customSet) otherwise.
- IsResourceProtected: MATCH (r {id}) RETURN coalesce(r.isProtected, false) — single scalar read.
- IsTenantAdmin (Task 10.6 stub): always returns false.
- GetInheritedPermissions (Task 10.4 stub): always returns Enumerable.Empty<Permission>().
- Added using Microsoft.Extensions.DependencyInjection; and using ThalabusInterfaces.Data.Neo4j.DataAccess; to using directives.
Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.
Test results: T1–T7 not yet run against a live Neo4j instance. Logic verified by code review against the plan. Note: SUPER_ADMIN bypass (T7) and permission inheritance (T4) will return incorrect results until Tasks 10.6 and 10.4 are implemented respectively.
Task 10.4 — Detail
1. Task Overview
Short Description
Implement GetInheritedPermissions(resourceId, groupIds) — a private helper in AclService that walks the [:CONTAINS] ancestor chain from the given resource toward the root, collecting all ACL grants for the specified groups at each ancestor level. The walk terminates at the first isProtected ancestor node (inclusive — that ancestor's grants are collected but no further ancestors are consulted).
Objective / Expected Result
- Returns the union of all permissions inherited from ancestor resources.
- Respects the isProtected barrier: stops at the nearest protected ancestor (inclusive).
- Returns an empty collection if the resource is the root or has no ancestor grants.
2. Context & Scope
Background / Reason for Change
ACL_Design.md specifies that sub-folders and documents inherit permissions from their parent folder. This traversal is the mechanism behind that inheritance: rather than copying grants onto every child at write time (fan-out), permissions are accumulated at read time by walking up the containment tree. The isProtected barrier ensures that confidential sections of the tree do not inadvertently expose their ancestors' grants to viewers below the barrier.
In Scope
- Implementing GetInheritedPermissions as a private async Task<IEnumerable<Permission>> in AclService.
- Using a single Cypher query to retrieve all ancestors ordered by proximity (nearest first), then iterating in C# to apply the isProtected barrier and collect grants.
- Each ancestor's grants are fetched with GetDirectGrants(ancestorId, groupIds).
Out of Scope
- isProtected on the resource itself — handled by GetEffectivePermissions (Task 10.3) before calling this method.
- Caching or memoisation of ancestor chains — out of scope.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/AclService.cs |
New private method GetInheritedPermissions |
Dependencies
| Task | Reason |
|---|---|
| Task 10.3 | GetDirectGrants helper must exist (also from 10.3) |
| Task 10.5 | isProtected barrier applied here |
| Task 1 | [:CONTAINS] must be the active containment relationship (former [:OWNS] for Dossier→Document) |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Cycle in the containment graph (should not happen, but possible due to data error) | Cypher variable-length path will raise a stack-overflow risk; set a max depth of 50 on the Cypher query |
Root dossier has no [:OWNS] relationship |
Cypher collects grants via [:GRANT] only; no [:OWNS] edge for root causes no extra grant match → correct |
| Very deep folder hierarchy (100+ levels) | Max depth cap of 50 is enforced in Cypher pattern |
Backward Compatibility Considerations
Private helper; no existing callers outside AclService.
4. Implementation Plan
Add the following private method to AclService:
private async Task<IEnumerable<Permission>> GetInheritedPermissions(
string resourceId, IList<string> groupIds) {
// Step 1: Get all ancestors ordered by proximity (nearest first),
// with a maximum depth cap of 50 to guard against pathological data.
var neo4j = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
var ancestorQuery = @"
MATCH path = (r {id: $resourceId})<-[:CONTAINS*1..50]-(ancestor)
RETURN ancestor.id AS ancestorId,
coalesce(ancestor.isProtected, false) AS isProtected,
length(path) AS depth
ORDER BY depth ASC";
var ancestorParams = new Dictionary<string, object?> { { "resourceId", resourceId } };
var ancestorRecords = await neo4j.ExecuteReadVectorAsync(ancestorQuery, ancestorParams);
// Step 2: Walk ancestors nearest-first; collect grants; stop at first protected ancestor.
var inherited = new HashSet<Permission>();
foreach (var record in ancestorRecords) {
var ancestorId = record["ancestorId"]?.ToString() ?? string.Empty;
var isProtected = record["isProtected"] is bool b && b;
var ancestorGrants = await GetDirectGrants(ancestorId, groupIds);
foreach (var entry in ancestorGrants)
foreach (var p in entry.EffectivePermissions)
inherited.Add(p);
if (isProtected) break; // inclusive barrier: include this ancestor's grants then stop
}
return inherited;
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Resource with unprotected parent that has Admin grant for group |
Parent's permissions collected |
| T2 | Resource with three ancestor levels, all unprotected | All three levels' grants are unioned |
| T3 | Resource with grandparent that is protected | Nearest unprotected parent's grants collected; protected grandparent's grants collected (inclusive); great-grandparent NOT included |
| T4 | Resource with no ancestors (root or direct child of root with no grants) | Returns empty collection |
| T5 | Depth exceeds 50 levels | Ancestors beyond depth 50 are not traversed (Cypher limit enforces this) |
Acceptance Criteria
- T1–T5 pass.
- The isProtected barrier is inclusive (the protected ancestor's own grants are included but nothing above it).
Implementation of these tests
Unit tests in ThalabusUnitTests/ACL/AclServiceInheritanceTests.cs. Mock INeo4jDataAccess to return controlled ancestor records and grants.
6. Post-Implementation
Date: 2026-05-05
What changed:
- ThalabusCore/Services/AclService.cs — replaced the GetInheritedPermissions stub with a full implementation:
- Cypher: MATCH path = (r {id: $resourceId})<-[:CONTAINS*1..50]-(ancestor) RETURN ancestor.id, coalesce(ancestor.isProtected, false), length(path) ORDER BY depth ASC — fetches all ancestors up to depth 50, nearest first.
- C# loop: calls GetDirectGrants(ancestorId, groupIds) for each ancestor and unions all EffectivePermissions into a HashSet<Permission>.
- isProtected barrier: after collecting the current ancestor’s grants, if ancestor.isProtected == true, breaks out of the loop (inclusive — the protected ancestor’s own grants are included but nothing above it).
- Depth cap of 50 in the Cypher pattern guards against pathological data or accidental cycles.
Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.
Test results: T1–T5 not yet run against a live Neo4j instance. Logic verified by code review against the plan. Note: IsTenantAdmin (Task 10.6) remains a stub returning false, so the SUPER_ADMIN bypass path through inheritance is not yet active.
Task 10.5 — Detail
1. Task Overview
Short Description
Document and verify the isProtected inheritance-break logic across both GetEffectivePermissions (Task 10.3) and GetInheritedPermissions (Task 10.4). Confirm that the two-level semantics are correctly implemented: (a) a protected resource ignores all ancestor grants and uses only its own; (b) a protected ancestor during traversal stops propagation of grants from further ancestors while including its own.
Objective / Expected Result
- No code changes beyond what Tasks 10.3 and 10.4 already produce.
- The logic is verified end-to-end through dedicated integration test scenarios.
- isProtected semantics are confirmed to match ACL_Design.md.
2. Context & Scope
Background / Reason for Change
The isProtected flag is the ACL design's "override feature" — it allows a folder or document to be sealed against inheritance from ancestors. This is used for confidential items inside shared folders. Getting this logic exactly right is critical: an off-by-one error in the traversal cut-off point could either leak permissions (under-protection) or wrongly deny access (over-protection). A dedicated task ensures this logic is explicitly tested, not just incidentally covered.
In Scope
- Validating the two distinct isProtected scenarios:
1. The target resource itself is isProtected = true → GetEffectivePermissions must NOT call GetInheritedPermissions; only direct grants on the target resource apply.
2. An ancestor in the traversal chain has isProtected = true → GetInheritedPermissions includes that ancestor's grants but does NOT continue to ancestors above it.
- Integration tests covering both scenarios with a real or mocked Neo4j graph.
Out of Scope
- Changing how isProtected is set or cleared on nodes — covered by Task 8.7 (SetProtected/Unprotect).
- Cascading isProtected to children — the flag is per-resource; children are not affected.
- Requiring SHARING permission to set isProtected — already specified in Task 8.7 and documented in ACL_Design.md.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/AclService.cs |
No new code — logic verified through tests |
ThalabusUnitTests/ACL/AclServiceProtectionTests.cs |
New test file |
Dependencies
| Task | Reason |
|---|---|
| Task 10.3 | GetEffectivePermissions with IsResourceProtected check |
| Task 10.4 | GetInheritedPermissions with isProtected barrier |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Entire path to root is protected nodes | Traversal stops at the first protected ancestor (inclusive); only that ancestor's grants propagate downward |
| Resource is protected AND has no direct grants | User gets no permissions (empty set). SUPER_ADMIN bypass still applies (Task 10.6). |
isProtected flag not present on old nodes (before migration) |
coalesce(r.isProtected, false) in all Cypher queries defaults to false — safe fallback |
Backward Compatibility Considerations
No code changes. Existing resources without isProtected default to false via Cypher coalesce.
4. Implementation Plan
No code to add — logic is already in place from Tasks 10.3 and 10.4. This task consists of:
- Review that
GetEffectivePermissionscallsIsResourceProtected(resourceId)and skipsGetInheritedPermissionswhen the result istrue. - Review that
GetInheritedPermissionsbreaks after collecting grants from a protected ancestor. - Add dedicated integration tests (see Testing Strategy below).
Verify the control flow:
GetEffectivePermissions(userId, resourceId):
└─ IsResourceProtected(resourceId) → true?
YES: return directGrants only (no inheritance)
NO:
└─ GetInheritedPermissions(resourceId, groupIds):
for each ancestor (nearest first):
collect grants
if ancestor.isProtected → break (inclusive)
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Target resource is isProtected = true; parent has broad grants |
Only target's own grants returned; parent's grants are NOT inherited |
| T2 | Target resource is NOT protected; grandparent is protected | Grants from: target (direct) + parent (not protected) + grandparent (protected, inclusive); great-grandparent NOT included |
| T3 | Target resource is NOT protected; NO ancestor is protected | All ancestor grants all the way to root are included |
| T4 | Protected resource with no direct grants | Returns []; SUPER_ADMIN bypass is the only way in |
| T5 | SUPER_ADMIN accesses a protected resource with no direct grants | Returns all 6 permissions |
Acceptance Criteria - T1: parent grants are absent from the result. - T2: grandparent's grants are present; great-grandparent's grants are absent. - T4: empty result confirmed. - T5: all 6 permissions confirmed.
Implementation of these tests
Integration tests in ThalabusUnitTests/ACL/AclServiceProtectionTests.cs using a mock Neo4j graph or a test-container Neo4j instance.
6. Post-Implementation
What changed: No new code beyond Tasks 10.3/10.4. Verified in ThalabusCore/Services/AclService.cs: GetEffectivePermissions skips GetInheritedPermissions when IsResourceProtected(resourceId) is true; GetInheritedPermissions breaks the ancestor loop immediately after collecting a protected ancestor's grants (inclusive barrier).
Test results: Control-flow verified by code review against T1–T5; dedicated integration tests in AclServiceProtectionTests.cs were not found in the repository as of this review — recommended as a follow-up if not already tracked elsewhere.
Task 10.6 — Detail
1. Task Overview
Short Description
Implement IsTenantAdmin(groupIds) — a private helper in AclService that returns true if any of the supplied group IDs corresponds to a group that holds the tenant-admin capability via graph traversal. This check is the SUPER_ADMIN bypass gate used by GetEffectivePermissions and indirectly by CheckAccess.
Objective / Expected Result
- IsTenantAdmin returns true if any group in groupIds is connected to a Capability {id: 'tenant-admin'} node via (Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'}).
- GetEffectivePermissions calls this as its first check and returns all permissions immediately if true.
- The bypass applies regardless of isProtected on any resource and regardless of any [:GRANT] relationships.
2. Context & Scope
Background / Reason for Change
ACL_Design.md states: "The DOCUMENT SUPER ADMIN role grants access to all documents and folders in the tenant, whether the entity is protected or not." SuperAdmin is identified by capability traversal — the Group node has no tenantAdmin property. Exactly one tenant group exists per tenant; it is provisioned at bootstrap (Task 0.2) by creating the (Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'}) chain and has no [:GRANT:<PERMISSION>] relationships on any resource. Separating the check into its own helper keeps GetEffectivePermissions readable and makes the bypass easy to mock in tests.
In Scope
- Implementing IsTenantAdmin(IList<string> groupIds) as a private async Task<bool> method in AclService.
- One Cypher query: traverse (Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'}) filtering by groupIds.
- Calling this at the top of GetEffectivePermissions (already scaffolded in Task 10.3).
Out of Scope
- Provisioning the tenant group — done at bootstrap (Task 0.2).
- Creating or managing [:GRANT] relationships for the tenant group — by design none exist.
- Restricting which resources a SuperAdmin can act on — by design they bypass all resource-level checks.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/AclService.cs |
New private method IsTenantAdmin |
Dependencies
| Task | Reason |
|---|---|
| Task 0.2 | tenant group capability chain must be provisioned before IsTenantAdmin can return true |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
groupIds is empty |
Short-circuit before the Cypher call → false; correct |
| tenant group capability chain accidentally removed | All super-admin users lose full access. This is a data-integrity concern; the audit log (Task 11) tracks group modifications. |
Backward Compatibility Considerations Private method; no external callers.
4. Implementation Plan
Add the following private method to AclService:
private async Task<bool> IsTenantAdmin(IList<string> groupIds) {
if (groupIds.Count == 0) return false;
var neo4j = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
// SuperAdmin is identified by capability traversal, not a node property.
// A group holding the 'tenant-admin' capability bypasses all resource permission checks.
var query = @"
MATCH (g:Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'})
WHERE g.id IN $groupIds
RETURN count(*) > 0 AS isTenantAdmin";
var parameters = new Dictionary<string, object?> { { "groupIds", groupIds } };
var result = await neo4j.ExecuteReadScalarAsync<bool>(query, parameters);
return result;
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Group node connected to Capability {id: 'tenant-admin'} |
IsTenantAdmin returns true |
| T2 | Group node has no [:HAS_ROLE] relationships (or chain does not reach tenant-admin) |
IsTenantAdmin returns false |
| T3 | groupIds is empty |
IsTenantAdmin returns false (short-circuit before Cypher) |
| T4 | CheckAccess for a SuperAdmin user on a protected resource with no direct grants |
Returns true |
| T5 | GetEffectivePermissions for a SuperAdmin user |
Returns all 6 permissions without Neo4j resource lookup |
Acceptance Criteria
- T1–T5 pass.
- IsTenantAdmin never inspects [:GRANT] relationships — confirmed by query review.
Implementation of these tests
Unit tests in ThalabusUnitTests/ACL/AclServiceSuperAdminTests.cs. Mock INeo4jDataAccess to return controlled results. Integration test with test-container Neo4j confirms correct behaviour for T1 and T2.
6. Post-Implementation
What changed: IsTenantAdmin is implemented as INeo4jAclRepository.IsTenantAdmin(groupIds) (queried via the Neo4jAclRepository data-access layer rather than inline INeo4jDataAccess calls in AclService), and is invoked from AclService.GetEffectivePermissions as the first check, returning AclRoles.Owner.Permissions immediately on a match and logging SuperAdminAccessUsed.
Test results: Behavior confirmed via code review of AclService.cs and Neo4jAclRepository.cs; no dedicated AclServiceSuperAdminTests.cs file was found in the repository as of this review — recommended as a follow-up if not already tracked elsewhere.
Task 10.7 — Detail
1. Task Overview
Short Description
Implement the four ACL management methods in AclService: GetAcl, SetAcl, AddPermission, and RemovePermission. These methods read and write [:GRANT] relationships between Group nodes and resource nodes (Dossier/Document), translating between the IAclEntry domain type and the [:GRANT] Neo4j relationship property schema.
Objective / Expected Result
- GetAcl(resourceId) returns all IAclEntry instances directly assigned to the resource.
- AddPermission(entry) upserts a single [:GRANT] relationship (MERGE semantics — one grant per group+resource pair).
- RemovePermission(resourceId, subject) deletes the [:GRANT] relationship for the given group.
- SetAcl(resourceId, entries) atomically replaces ALL grants on the resource with the provided set.
- Callers are expected to have verified SHARING permission before invoking these methods (enforced at the call sites in Task 12).
2. Context & Scope
Background / Reason for Change
GetEffectivePermissions (Task 10.3) fetches direct grants as part of permission resolution using an internal helper. GetAcl exposes the same data to external callers (e.g. a sharing UI that needs to list who has access). AddPermission, RemovePermission, and SetAcl are the mutation operations used when an admin grants or revokes access.
In Scope
- GetAcl(resourceId): query all (g:Group)-[grant]->(r {id: $resourceId}) where type(grant) STARTS WITH 'GRANT:' and map to IEnumerable<IAclEntry>. Each row includes type(grant) (e.g. GRANT:READ) — the permission is encoded in the relationship type, not a property.
- AddPermission(entry): create one [:GRANT:<PERMISSION>] relationship per effective permission in the entry (e.g. [:GRANT:READ], [:GRANT:SHARING]). APOC apoc.merge.relationship is used for dynamic relationship types. Each relationship stores only grantedAt and grantedBy as metadata — role is a C# abstraction and is not persisted in the graph.
- RemovePermission(resourceId, subject): MATCH and DELETE all [:GRANT:*] relationships between the group and the resource.
- SetAcl(resourceId, entries): in a single Cypher write, delete all existing grants on the resource then create new ones for each entry. If entries is empty, all grants are revoked.
- Mapping IAclEntry ↔ Neo4j typed-relationship schema (serialize Permission[] as uppercase strings; reconstruct IRole via ResolveRoleFromPermissions).
Out of Scope
- Permission pre-checks (SHARING required) — the caller is responsible; these methods are the write primitives.
- Propagating grant changes to descendants (fan-out) — the design uses read-time traversal, so no fan-out is needed.
- Audit logging of grant changes — covered by Task 11.4.
- Ownership transfer — [:OWNS] relationship is managed by ResourceService.MoveResource, not by AclService.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/AclService.cs |
Replace four NotImplementedException stubs |
Dependencies
| Task | Reason |
|---|---|
| Task 9.3 | IAclEntry, AclEntry used for mapping |
| Task 9.2 | AclRoles.For(roleType) used to reconstruct IRole from stored string |
| Task 9.3 | IAclEntry and AclEntry used for mapping results |
| Task 10.3 | MapToAclEntry helper already introduced there; reused here |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
SetAcl with empty entries list — should revoke all access |
Delete-then-skip-insert branch; verified in tests |
AddPermission called twice for the same (group, resource) |
MERGE is idempotent; second call updates properties in place |
RemovePermission on a non-existent grant |
MATCH+DELETE finds nothing; Cypher is a no-op; no exception |
SetAcl partial failure (delete succeeded, inserts fail halfway) |
Wrap in a single Cypher write transaction via ExecuteWriteAsync; Neo4j rolls back atomically on error |
Resource node labels differ (Dossier vs Document) — Cypher must match both |
Use generic {id: $resourceId} without a label constraint in GRANT queries; both node types share the id property |
Backward Compatibility Considerations
Replaces NotImplementedException stubs. No existing production callers yet (Task 12 wires call sites).
4. Implementation Plan
Replace the four stub methods in AclService with the following:
GetAcl
public async Task<IEnumerable<IAclEntry>> GetAcl(string resourceId) {
var neo4j = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
var query = @"
MATCH (g:Group)-[grant]->(r {id: $resourceId})
WHERE type(grant) STARTS WITH 'GRANT:'
RETURN g.id AS groupId,
type(grant) AS grantType,
grant.grantedAt AS grantedAt,
grant.grantedBy AS grantedBy";
var parameters = new Dictionary<string, object?> { { "resourceId", resourceId } };
var records = await neo4j.ExecuteReadVectorAsync(query, parameters);
return MapToAclEntries(records, resourceId);
}
AddPermission
public async Task AddPermission(IAclEntry entry, string effectiveUserId) {
var neo4j = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
// Each effective permission is stored as a separate [:GRANT:<PERMISSION>] relationship
// (e.g. [:GRANT:READ], [:GRANT:SHARING]). APOC is required for dynamic rel types.
var query = @"
MATCH (g:Group {id: $groupId}), (r {id: $resourceId})
UNWIND $permissions AS perm
CALL apoc.merge.relationship(g, 'GRANT:' + perm, {},
{grantedAt: $grantedAt, grantedBy: $grantedBy}, r, {})
YIELD rel
RETURN count(rel)";
var parameters = new Dictionary<string, object?> {
{ "groupId", entry.GroupId },
{ "resourceId", entry.ResourceId },
{ "permissions", entry.EffectivePermissions
.Select(p => p.ToString().ToUpperInvariant())
.ToArray() },
{ "grantedAt", entry.UpdatedAt.ToString("o") },
{ "grantedBy", entry.GrantedBy ?? effectiveUserId }
};
await neo4j.ExecuteWriteAsync(query, parameters);
}
RemovePermission
public async Task RemovePermission(string resourceId, string groupId, string effectiveUserId) {
var neo4j = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
var query = @"
MATCH (g:Group {id: $groupId})-[r]->(res {id: $resourceId})
WHERE type(r) STARTS WITH 'GRANT:'
DELETE r";
var parameters = new Dictionary<string, object?> {
{ "groupId", groupId },
{ "resourceId", resourceId }
};
await neo4j.ExecuteWriteAsync(query, parameters);
}
SetAcl
public async Task SetAcl(string resourceId, IEnumerable<IAclEntry> entries, string effectiveUserId) {
var entryList = entries.ToList();
var neo4j = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
// Build per-permission rows so we can UNWIND them in a single Cypher statement.
var grantRows = entryList
.SelectMany(entry => entry.EffectivePermissions.Select(p => new Dictionary<string, object?> {
{ "groupId", entry.GroupId },
{ "permission", p.ToString().ToUpperInvariant() },
{ "grantedAt", entry.UpdatedAt.ToString("o") },
{ "grantedBy", entry.GrantedBy ?? effectiveUserId }
}))
.ToArray();
// Atomic: delete all existing GRANT relationships on the resource and
// re-create the new set in a single write transaction via APOC merge.
var atomicQuery = @"
MATCH (res {id: $resourceId})
OPTIONAL MATCH (g_old:Group)-[r_old]->(res)
WHERE type(r_old) STARTS WITH 'GRANT:'
DELETE r_old
WITH res
UNWIND $grantRows AS row
MATCH (g:Group {id: row.groupId})
CALL apoc.merge.relationship(g, 'GRANT:' + row.permission, {},
{grantedAt: row.grantedAt, grantedBy: row.grantedBy}, res, {})
YIELD rel
RETURN count(rel)";
var parameters = new Dictionary<string, object?> {
{ "resourceId", resourceId },
{ "grantRows", grantRows }
};
await neo4j.ExecuteWriteAsync(atomicQuery, parameters);
}
Note: The delete and re-create steps execute inside a single Neo4j write transaction. No intermediate state is observable between grant revocation and re-creation. If
entriesis empty, all existing grants are deleted andUNWINDover an empty list is a no-op — the resource ends up with no grants.
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | AddPermission for group G on resource R with role Manager |
One [:GRANT:<PERMISSION>] relationship per Manager permission exists, each with grantedAt and grantedBy |
| T2 | AddPermission called twice for same (G, R) with same role |
Existing [:GRANT:<PERMISSION>] relationships are merged in place; grantedAt and grantedBy updated |
| T3 | RemovePermission for group G on resource R |
[:GRANT] relationship no longer exists |
| T4 | RemovePermission when no grant exists |
No error; no-op |
| T5 | GetAcl(resourceId) returns all direct grants |
Each returned IAclEntry has correct GroupId, Role (inferred from permissions), EffectivePermissions |
| T6 | SetAcl with two entries (e.g. Manager + Viewer) |
Existing grants deleted; new [:GRANT:<PERMISSION>] typed relationships created — one per effective permission per entry |
| T7 | SetAcl with empty list |
All existing grants deleted; resource has no grants |
| T8 | GetAcl after SetAcl |
Returns exactly the entries passed to SetAcl |
| T9 | AddPermission with PermissionOverrides = [] (empty list) |
Grant created; EffectivePermissions is empty (deny all) |
Acceptance Criteria
- T1–T9 pass.
- SetAcl is atomic — no partial state visible between delete and re-create within a single session.
Implementation of these tests
Integration tests in ThalabusUnitTests/ACL/AclServiceGrantTests.cs against a test-container Neo4j instance (or mocked INeo4jDataAccess for unit tests). End-to-end scenario: AddPermission → GetAcl → RemovePermission → GetAcl (expect empty).
6. Post-Implementation
Date: 2026-05-05
What changed:
- ThalabusCore/Services/AclService.cs — replaced all four NotImplementedException stubs:
- GetAcl: MATCH (g:Group)-[grant]->(r {id}) WHERE type(grant) STARTS WITH 'GRANT:' RETURN groupId, grantType, grantedAt, grantedBy — reuses MapToAclEntries for mapping.
- AddPermission: UNWINDs the effective permissions array and calls apoc.merge.relationship(g, 'GRANT:' + perm, {}, {grantedAt, grantedBy}, r, {}) — one typed relationship per permission; MERGE is idempotent.
- RemovePermission: MATCH (g:Group {id})-[r]->(res {id}) WHERE type(r) STARTS WITH 'GRANT:' DELETE r — no-op when no grant exists.
- SetAcl: single Cypher write transaction — OPTIONAL MATCH + DELETE all existing GRANT rels, then UNWIND $grantRows + apoc.merge.relationship to re-create the new set. If entries is empty the UNWIND is a no-op (all grants revoked atomically).
Build result: Zero CS errors. ThalabusWeb build clean.
Test results: T1–T9 not yet run against a live Neo4j instance. Logic verified by code review against plan. APOC availability required at runtime.
Task 10.7.1 — Detail
1. Task Overview
Short Description
Register IAclService and AclService as a singleton in ThalabusWeb/Program.cs so that AclService can be injected into all call sites that enforce ACL (Task 12) and into any Blazor components that need to query effective permissions.
Objective / Expected Result
- IAclService resolves from the DI container at application startup.
- No InvalidOperationException at startup.
- Registration is placed after IGroupService (Task 3.4.1) since AclService depends on IGroupService.
2. Context & Scope
Background / Reason for Change
AclService depends on IGroupService (for GetGroups), which must already be registered. The registration order in Program.cs must respect this dependency chain.
In Scope
- Adding builder.Services.AddSingleton<IAclService, AclService>() to Program.cs.
- Ensuring placement after IGroupService registration.
Out of Scope
- Any logic change in AclService.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusWeb/Program.cs |
One new AddSingleton line |
Dependencies
| Task | Reason |
|---|---|
| Task 10.2–10.7 | AclService must be fully implemented |
| Task 3.4.1 | IGroupService must be registered first |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
IAclService registered before IGroupService |
DI throws at resolution time; fix by ordering the lines correctly |
Backward Compatibility Considerations Purely additive. No existing registrations are modified.
4. Implementation Plan
In ThalabusWeb/Program.cs, after the IGroupService registration line:
builder.Services.AddSingleton<IGroupService, GroupService>(); // Task 3.4.1
builder.Services.AddSingleton<IAclService, AclService>(); // Task 10.7.1 — must follow IGroupService
Add using ThalabusInterfaces.Services; and using ThalabusCore.Services; if not already present.
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Application starts | No DI exception |
| T2 | app.Services.GetRequiredService<IAclService>() |
Returns AclService instance |
Acceptance Criteria
- Application starts without InvalidOperationException.
Implementation of these tests Application startup smoke test. Covered by the integration tests in Task 10.7.
6. Post-Implementation
Date: 2026-05-05
What changed:
- ThalabusWeb/Program.cs line 87: builder.Services.AddSingleton<IAclService, AclService>(); — already added during Task 10.2. Registration placed after IGroupService to satisfy the constructor dependency.
Build result: Zero CS errors.
Test results: Application starts without InvalidOperationException. AclService.Singleton is set on first resolution. Startup smoke test passed.
Task 11 — Audit Service
| # | Task | Status |
|---|---|---|
| 11.1 | Define AuditEventType enum |
implemented |
| 11.2 | Define IAuditService interface |
implemented |
| 11.3 | Implement AuditService.Log |
implemented |
| 11.4 | Wire audit calls into Resource Service, ACL Service, and Group Service | implemented |
Task 11.1 — Detail
1. Task Overview
Short Description
Create a AuditEventType enum in ThalabusInterfaces/ACL/ declaring all 16 business-level audit event types defined in ACL_Technical_Interfaces.md. This is a pure type declaration with no runtime logic.
Objective / Expected Result
- A file ThalabusInterfaces/ACL/AuditEventType.cs exists with all 16 PascalCase enum values.
- IAuditService (Task 11.2) compiles against this enum.
2. Context & Scope
Background / Reason for Change
ACL_Implementation_Gaps.md confirms that no structured audit trail exists today — only a developer-diagnostic Log.cs utility. The ACL design requires a queryable, typed audit trail of business events (permission grants, resource mutations, access decisions). AuditEventType is the closed set of event discriminators for this trail.
In Scope
- Defining the AuditEventType enum with 16 values in namespace ThalabusInterfaces.ACL.
- Using PascalCase names per C# conventions (e.g. ResourceCreated maps to 'RESOURCE_CREATED' in the TypeScript design).
Out of Scope
- Any runtime logic, helper methods, or extension methods on AuditEventType.
- Implementing the service that emits these events — covered by Task 11.3.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusInterfaces/ACL/AuditEventType.cs |
New file |
Dependencies None — standalone type declaration.
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Future need to add new event types | New values can be appended without breaking existing stored strings; no migration needed |
Stored string value (e.g. "ResourceCreated") must round-trip via Enum.Parse |
Enum.Parse<AuditEventType>(str, ignoreCase: true) handles all 16 values; verified in Task 11.3 tests |
Backward Compatibility Considerations New file. No existing callers.
4. Implementation Plan
Create ThalabusInterfaces/ACL/AuditEventType.cs:
namespace ThalabusInterfaces.ACL {
/// <summary>
/// Discriminator for all structured business-level audit events.
/// Stored as a string on the AuditEvent Neo4j node (e.g. "ResourceCreated").
/// Maps to the TypeScript AuditEventType union defined in ACL_Technical_Interfaces.md.
/// </summary>
public enum AuditEventType {
/// <summary>A new Dossier or Document was created.</summary>
ResourceCreated,
/// <summary>A resource was moved to a different parent dossier.</summary>
ResourceMoved,
/// <summary>A resource was renamed.</summary>
ResourceRenamed,
/// <summary>A resource was soft-deleted.</summary>
ResourceDeleted,
/// <summary>A soft-deleted resource was restored.</summary>
ResourceRestored,
/// <summary>One or more [:GRANT:<PERMISSION>] typed relationships were created (group received permissions on a resource).</summary>
PermissionGranted,
/// <summary>One or more [:GRANT:<PERMISSION>] typed relationships were deleted (group's permissions on a resource were revoked).</summary>
PermissionRevoked,
/// <summary>A resource's isProtected flag was set to true.</summary>
ResourceProtected,
/// <summary>A resource's isProtected flag was cleared.</summary>
ResourceUnprotected,
/// <summary>The ownership ([:OWNS] edge) of a resource changed (e.g. via MoveResource into a differently-owned dossier).</summary>
OwnershipChanged,
/// <summary>A bulk ACL replacement (SetAcl) was performed on a resource.</summary>
SharingActionPerformed,
/// <summary>CheckAccess returned false for a user on a resource.</summary>
AccessDenied,
/// <summary>CheckAccess returned true for a user on a resource.</summary>
AccessGranted,
/// <summary>A SUPER_ADMIN bypassed isProtected or ACL enforcement on a resource.</summary>
SuperAdminAccessUsed,
/// <summary>A user was added to or removed from a group.</summary>
GroupMembershipChanged,
/// <summary>A user was removed from the tenant (and their hidden system group was dissolved).</summary>
UserRemovedFromTenant
}
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Solution compiles after adding the file | No compile errors |
| T2 | Enum.Parse<AuditEventType>("ResourceCreated", ignoreCase: true) |
Returns AuditEventType.ResourceCreated |
| T3 | AuditEventType.PermissionGranted.ToString() |
Returns "PermissionGranted" |
| T4 | Enum.GetValues<AuditEventType>().Length |
Returns 16 |
Acceptance Criteria - T1–T4 pass; all 16 values present and round-trip through string serialisation.
Implementation of these tests
Unit tests in ThalabusUnitTests/ACL/AuditEventTypeTests.cs.
6. Post-Implementation
Placeholder — to be populated after implementation.
What changed:
AuditEventType.cscreatedTest results: outcomes of T1–T4
Task 11.2 — Detail
1. Task Overview
Short Description
Create the IAuditService interface in ThalabusInterfaces/Services/ declaring the single audit operation: emitting a structured audit event. Events are written to the existing Thalabus Log system using a dedicated AUDIT container (log file). They are not stored in Neo4j.
Objective / Expected Result
- IAuditService exists with one method: Log(...).
- All callers in ResourceService, AclService, and GroupService (Task 11.4) depend on this abstraction.
- Audit events are findable in thalabus_AUDIT.log by grepping for the structured key [NAMESPACE.AuditEventType].
2. Context & Scope
Background / Reason for Change
Audit events must be stored alongside other system logs and be retrievable by a structured key without introducing new infrastructure (no new database, no new store). The existing Log utility already writes rotating log files per containerName; using containerName = "AUDIT" produces a dedicated thalabus_AUDIT.log file that can be searched independently of the diagnostic log.
Namespace key scheme
Each log entry is prefixed with [NAMESPACE.AuditEventType], making it grep-searchable:
| Namespace | AuditEventType values |
|---|---|
RESOURCE_LIFECYCLE |
ResourceCreated, ResourceMoved, ResourceRenamed, ResourceDeleted, ResourceRestored, ResourceProtected, ResourceUnprotected, OwnershipChanged |
RESOURCE_PERMISSION |
PermissionGranted, PermissionRevoked, SharingActionPerformed |
ACCESS_CONTROL |
AccessDenied, AccessGranted, SuperAdminAccessUsed |
USER_MANAGEMENT |
GroupMembershipChanged, UserRemovedFromTenant |
Example log entry produced in thalabus_AUDIT.log:
INFO | 2026-04-27 10:23:45.123 | AUDIT | ... | [RESOURCE_PERMISSION.PermissionGranted] id=550e8400-... effectiveUserId=usr-1 resourceId=res-42 targetId=grp-7 metadata={"role":"OWNER"}
To find all permission events: grep "\[RESOURCE_PERMISSION\." thalabus_AUDIT.log
To find a specific resource: grep "resourceId=res-42" thalabus_AUDIT.log
In Scope
- Declaring IAuditService in ThalabusInterfaces/Services/IAuditService.cs with a single Log(...) method.
- OccurredAt and a UUID id are assigned internally by the service.
Out of Scope
- GetEvents / GetEventsByActor — log files are not a queryable store; retrieval is via external tools (grep, log aggregators). These methods are intentionally omitted.
- Defining AuditEventType — covered by Task 11.1.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusInterfaces/Services/IAuditService.cs |
New file |
Dependencies
| Task | Reason |
|---|---|
| Task 11.1 | AuditEventType enum must be declared |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Log called from a high-frequency code path (e.g. CheckAccess) |
AccessGranted / AccessDenied events emitted selectively (see Task 11.4); not on every call |
Log failure must not break the calling operation |
AuditService.Log catches all exceptions internally and falls back to DiagLog.Warning |
Backward Compatibility Considerations New file. No existing callers.
4. Implementation Plan
Create ThalabusInterfaces/Services/IAuditService.cs:
using ThalabusInterfaces.ACL;
namespace ThalabusInterfaces.Services {
/// <summary>
/// Business-level audit trail service.
/// Writes structured audit events to the system log file (AUDIT container).
/// Events are findable by grepping for [NAMESPACE.AuditEventType] in thalabus_AUDIT.log.
/// Distinct from the diagnostic Log utility — audit events represent business actions,
/// not developer/operations diagnostics.
/// </summary>
public interface IAuditService {
/// <summary>
/// Records a new audit event. Assigns a UUID id and OccurredAt timestamp internally.
/// Fire-and-forget: exceptions are swallowed inside the implementation.
/// The call does not block on I/O (log write is synchronous but very fast).
/// </summary>
/// <param name="type">The type of business event — determines the log namespace key.</param>
/// <param name="effectiveUserId">The user who performed the action.</param>
/// <param name="resourceId">The primary resource involved (optional).</param>
/// <param name="targetId">A secondary target, e.g. new parent dossier or group ID (optional).</param>
/// <param name="metadata">Additional context key-value pairs (optional; values must be JSON-serialisable).</param>
Task Log(
AuditEventType type,
string effectiveUserId,
string? resourceId = null,
string? targetId = null,
IReadOnlyDictionary<string, object?>? metadata = null);
}
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Solution compiles after adding the file | No compile errors |
| T2 | AuditService implements IAuditService (Task 11.3) |
No missing-member errors |
| T3 | Mock IAuditService resolves in DI container |
No DI resolution exceptions |
Acceptance Criteria - T1–T3 pass; solution compiles.
Implementation of these tests Compile-time verification at Task 11.3. DI smoke test at Task 11.4.
6. Post-Implementation
Date: 2026-05-06
What changed:
- ThalabusInterfaces/Services/IAuditService.cs — new file created.
- Single method Task Log(AuditEventType eventType, string effectiveUserId, string? resourceId = null, string? targetId = null, Dictionary<string, object?>? metadata = null).
- All parameters beyond eventType and effectiveUserId are optional — callers only supply what is relevant for the event type.
- XML doc on the method and on the interface itself describing the log destination, namespace-key scheme, and the swallowed-failure contract.
Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero CS errors.
Test results: T1 (compile) — passed. T2 and T3 will be verified once AuditService is implemented in Task 11.3.
Task 11.3 — Detail
1. Task Overview
Short Description
Create AuditService in ThalabusCore/Services/ and implement Log — the method that writes a structured audit entry to the existing Thalabus Log system using containerName = "AUDIT". This produces a dedicated thalabus_AUDIT.log file alongside the other system logs. No Neo4j writes; no new infrastructure.
Objective / Expected Result
- A call to Log(type, effectiveUserId, ...) writes one line to thalabus_AUDIT.log in the format [NAMESPACE.AuditEventType] id=... effectiveUserId=... resourceId=... targetId=... metadata={...}.
- The NAMESPACE is determined by the event type (see Task 11.2 namespace key table).
- Failures are swallowed; the calling operation always continues normally.
2. Context & Scope
Background / Reason for Change
Audit events must live alongside other system logs and be retrievable by a structured key. The existing Log utility supports a containerName parameter that routes entries to a dedicated file (thalabus_AUDIT.log), satisfying both requirements without new infrastructure.
In Scope
- AuditService class in ThalabusCore/Services/AuditService.cs implementing IAuditService.
- No constructor parameters — AuditService has no external dependencies (the Log utility is a static class).
- Log(...) builds the structured message string, then calls DiagLog.Info(message, containerName: "AUDIT").
- The static ThalabusInterfaces.Data.Logging.Log class is aliased as DiagLog inside the file to avoid name collision with the IAuditService.Log(...) method.
- AuditEventType → namespace mapping is defined as a static dictionary inside AuditService.
Out of Scope
- Any Neo4j interaction — no INeo4jDataAccess dependency.
- GetEvents / GetEventsByActor — log files are not a queryable store; these methods are not part of IAuditService (see Task 11.2).
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/AuditService.cs |
New file; full Log implementation |
Dependencies
| Task | Reason |
|---|---|
| Task 11.2 | IAuditService interface must be declared |
| Task 11.1 | AuditEventType enum (for namespace mapping) must be declared |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
DiagLog.Info throws (e.g. disk full) |
Wrap the entire body in try/catch; swallow silently — the Log utility itself already swallows internally, but a double guard costs nothing |
Metadata contains a non-serialisable type |
JsonSerializer.Serialize throws; caught by the outer try/catch; event is silently dropped |
Name collision between static Log class and IAuditService.Log(...) method |
Resolved by aliasing the static class: using DiagLog = ThalabusInterfaces.Data.Logging.Log; |
Backward Compatibility Considerations New file. No existing callers.
4. Implementation Plan
Create ThalabusCore/Services/AuditService.cs:
using System.Text.Json;
using ThalabusInterfaces.ACL;
using ThalabusInterfaces.Services;
using DiagLog = ThalabusInterfaces.Data.Logging.Log;
namespace ThalabusCore.Services {
public class AuditService : IAuditService {
private static readonly IReadOnlyDictionary<AuditEventType, string> NamespaceMap =
new Dictionary<AuditEventType, string> {
{ AuditEventType.ResourceCreated, "RESOURCE_LIFECYCLE" },
{ AuditEventType.ResourceMoved, "RESOURCE_LIFECYCLE" },
{ AuditEventType.ResourceRenamed, "RESOURCE_LIFECYCLE" },
{ AuditEventType.ResourceDeleted, "RESOURCE_LIFECYCLE" },
{ AuditEventType.ResourceRestored, "RESOURCE_LIFECYCLE" },
{ AuditEventType.ResourceProtected, "RESOURCE_LIFECYCLE" },
{ AuditEventType.ResourceUnprotected, "RESOURCE_LIFECYCLE" },
{ AuditEventType.OwnershipChanged, "RESOURCE_LIFECYCLE" },
{ AuditEventType.PermissionGranted, "RESOURCE_PERMISSION" },
{ AuditEventType.PermissionRevoked, "RESOURCE_PERMISSION" },
{ AuditEventType.SharingActionPerformed, "RESOURCE_PERMISSION" },
{ AuditEventType.AccessDenied, "ACCESS_CONTROL" },
{ AuditEventType.AccessGranted, "ACCESS_CONTROL" },
{ AuditEventType.SuperAdminAccessUsed, "ACCESS_CONTROL" },
{ AuditEventType.GroupMembershipChanged, "USER_MANAGEMENT" },
{ AuditEventType.UserRemovedFromTenant, "USER_MANAGEMENT" },
};
public Task Log(
AuditEventType type,
string effectiveUserId,
string? resourceId = null,
string? targetId = null,
IReadOnlyDictionary<string, object?>? metadata = null) {
try {
var ns = NamespaceMap.TryGetValue(type, out var n) ? n : "AUDIT";
var id = Guid.NewGuid().ToString();
var sb = new System.Text.StringBuilder();
sb.Append($"[{ns}.{type}] id={id} effectiveUserId={effectiveUserId}");
if (resourceId != null) sb.Append($" resourceId={resourceId}");
if (targetId != null) sb.Append($" targetId={targetId}");
if (metadata != null) sb.Append($" metadata={JsonSerializer.Serialize(metadata)}");
DiagLog.Info(sb.ToString(), containerName: "AUDIT");
} catch {
// Swallow silently — audit failures must never interrupt the calling operation.
}
return Task.CompletedTask;
}
}
}
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Log(PermissionGranted, effectiveUserId, resourceId, targetId, metadata) |
Line written to thalabus_AUDIT.log matching [RESOURCE_PERMISSION.PermissionGranted] id=... effectiveUserId=... resourceId=... targetId=... metadata={...} |
| T2 | Log(ResourceCreated, effectiveUserId) with no optional fields |
Line written; no resourceId, targetId, or metadata segments in the output |
| T3 | Log(AccessDenied, ...) |
Namespace in log line is ACCESS_CONTROL |
| T4 | Log when Metadata contains a non-serialisable value |
Exception swallowed; no throw propagates to caller |
| T5 | Call Log 3 times in rapid succession |
3 distinct lines in thalabus_AUDIT.log, each with a different id UUID |
Acceptance Criteria
- T1–T3, T5: log lines present in thalabus_AUDIT.log with correct content.
- T4: no exception propagates.
Implementation of these tests
Unit tests in ThalabusUnitTests/ACL/AuditServiceTests.cs. Read thalabus_AUDIT.log after each call to assert log content.
6. Post-Implementation
Date: 2026-05-06
What changed:
- ThalabusCore/Services/AuditService.cs — new file created; implements IAuditService.
- Static NamespaceMap dictionary maps all 16 AuditEventType values to their 4 namespace strings (RESOURCE_LIFECYCLE, RESOURCE_PERMISSION, ACCESS_CONTROL, USER_MANAGEMENT).
- Log(...) builds the structured message [NAMESPACE.EventType] id=<uuid> effectiveUserId=... resourceId=... targetId=... metadata={...} (optional segments omitted when the argument is null).
- Calls DiagLog.Info(message, containerName: "AUDIT") — ThalabusInterfaces.Data.Logging.Log aliased as DiagLog to avoid name collision with the IAuditService.Log method.
- Entire body wrapped in try/catch that swallows silently — audit failures never interrupt the calling operation.
- metadata serialised with System.Text.Json.JsonSerializer.Serialize; serialisation failures are covered by the outer catch.
- No constructor parameters — DiagLog is a static class; no DI dependency.
Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero CS errors.
Test results: T1–T5 not yet run. Logic verified by code review. AuditService must be registered in DI (Task 11.4) before T2 (implements interface) and T3 (DI smoke) can be confirmed at runtime.
Task 11.4 — Detail
1. Task Overview
Short Description
Wire IAuditService.Log calls into ResourceService, AclService, and GroupService at the points where ACL-relevant business events occur. Each service receives IAuditService via constructor injection and calls Log after the primary operation succeeds.
Objective / Expected Result
- All resource mutations, permission changes, and group membership changes emit the correct AuditEventType.
- Log is called after a successful write — not before, and not in a way that rolls back the primary operation on failure.
- No audit calls on read-only operations (GetResource, CheckAccess) except the two selective cases below.
2. Context & Scope
Background / Reason for Change
Without audit call wiring, AuditService exists but is never invoked. This task connects the service to all the event sources defined in Tasks 8.x, 10.x, and 3.x.
In Scope
| Event | Source method | effectiveUserId |
resourceId |
targetId |
metadata |
|---|---|---|---|---|---|
ResourceCreated |
ResourceService.CreateDossier, CreateDocument |
ownerGroupId |
new resource id | parentId |
{ "name": ... } |
ResourceMoved |
ResourceService.MoveResource |
effectiveUserId param |
resourceId |
new parentId |
{ "oldParentId": ... } |
ResourceRenamed |
ResourceService.RenameResource |
effectiveUserId param |
resourceId |
— | { "oldName": ..., "newName": ... } |
ResourceDeleted |
ResourceService.DeleteResource |
effectiveUserId param |
resourceId |
— | — |
ResourceRestored |
ResourceService.RestoreResource |
effectiveUserId param |
resourceId |
— | — |
ResourceProtected |
ResourceService.SetProtected |
effectiveUserId param |
resourceId |
— | — |
ResourceUnprotected |
ResourceService.Unprotect |
effectiveUserId param |
resourceId |
— | — |
PermissionGranted |
AclService.AddPermission |
effectiveUserId param |
entry.ResourceId |
entry.GroupId |
{ "role": ... } |
PermissionRevoked |
AclService.RemovePermission |
effectiveUserId param |
resourceId |
groupId |
— |
SharingActionPerformed |
AclService.SetAcl |
effectiveUserId param |
resourceId |
— | { "entryCount": ... } |
OwnershipChanged |
ResourceService.MoveResource (when owning group changes) |
effectiveUserId param |
resourceId |
new owning group ID | — |
GroupMembershipChanged |
GroupService.AddMember, RemoveMember |
effectiveUserId param |
— | groupId |
{ "userId": ..., "action": "added\|removed" } |
GroupDeleted |
GroupService.DeleteGroup (or equivalent) |
effectiveUserId param |
— | groupId |
— |
SuperAdminAccessUsed |
AclService.IsTenantAdmin (when bypass applies) |
userId param |
resourceId |
— | — |
AccessDenied |
AclService.CheckAccess (when result is false, for all denied access attempts including protected resources) |
userId param |
resourceId |
— | { "permission": ... } |
Out of Scope
- AccessGranted — too noisy for routine logging; not wired.
- UserRemovedFromTenant — wired in UserService.RemoveUser, which is outside the ACL service scope; deferred to a separate task.
- Retroactive audit events for data that existed before this task is implemented.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/ResourceService.cs |
Add IAuditService constructor parameter; wire 7 event calls |
ThalabusCore/Services/AclService.cs |
Add IAuditService constructor parameter; wire 4 event calls |
ThalabusCore/Services/GroupService.cs |
Add IAuditService constructor parameter; wire 2 event calls |
Dependencies
| Task | Reason |
|---|---|
| Task 11.3 | AuditService.Log must be implemented |
| Tasks 8.2–8.8 | ResourceService methods must exist |
| Tasks 10.3, 10.7 | AclService methods must exist |
| Task 3.4 | GroupService.AddMember / RemoveMember must exist |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Log called before the primary write succeeds (wrong ordering) |
Always call await _auditService.Log(...) after await primaryOperation(...) |
Log throws despite the try/catch in AuditService |
The try/catch is inside AuditService.Log — the call site does not need its own try/catch |
effectiveUserId not available in a method that doesn't take it (e.g. CreateDossier) |
Use ownerGroupId as a proxy for effectiveUserId at creation time; document this approximation |
OwnershipChanged emitted only when the owning group actually changes in MoveResource |
Compare old owner group (resolved via GetOwnerGroupId before the move) with new parent's owner group; skip OwnershipChanged if they are the same |
Backward Compatibility Considerations
Constructor signatures of ResourceService, AclService, and GroupService gain a new IAuditService parameter. IAuditService is registered in Program.cs as part of this task (see Step 5 in the implementation plan).
4. Implementation Plan
Pattern for each service — add IAuditService as a constructor parameter and store in a private field AuditService. Then after each primary operation succeeds, call:
await AuditService.Log(AuditEventType.ResourceCreated, effectiveUserId, resourceId: newId,
metadata: new Dictionary<string, object?> { { "name", name } });
ResourceService additions (representative — apply to all 7 events):
// In constructor:
private readonly IAuditService AuditService;
public ResourceService(IServiceProvider serviceProvider, IAuditService auditService) {
ServiceProvider = serviceProvider;
AuditService = auditService;
}
// After CreateDossier succeeds:
await AuditService.Log(AuditEventType.ResourceCreated, ownerGroupId,
resourceId: newDossier.Id,
metadata: new Dictionary<string, object?> { { "name", name }, { "parentId", parentId } });
// After MoveResource succeeds — emit ResourceMoved and conditionally OwnershipChanged:
await AuditService.Log(AuditEventType.ResourceMoved, effectiveUserId,
resourceId: resourceId, targetId: newParentId,
metadata: new Dictionary<string, object?> { { "oldParentId", oldParentId } });
if (oldOwnerGroupId != newOwnerGroupId)
await AuditService.Log(AuditEventType.OwnershipChanged, effectiveUserId,
resourceId: resourceId, targetId: newOwnerGroupId); // newOwnerGroupId from GetOwnerGroupId(newParentId)
AclService additions (representative):
// After AddPermission succeeds:
await AuditService.Log(AuditEventType.PermissionGranted, effectiveUserId,
resourceId: entry.ResourceId, targetId: entry.GroupId,
metadata: new Dictionary<string, object?> { { "role", entry.Role.Name.ToString() } });
// After RemovePermission succeeds:
await AuditService.Log(AuditEventType.PermissionRevoked, effectiveUserId,
resourceId: resourceId, targetId: groupId);```
**`GroupService` additions (representative):**
```csharp
// After AddMember succeeds:
await AuditService.Log(AuditEventType.GroupMembershipChanged, effectiveUserId,
targetId: groupId,
metadata: new Dictionary<string, object?> { { "userId", userId }, { "action", "added" } });
Step 5 — Register IAuditService in Program.cs
In ThalabusWeb/Program.cs, after the IGroupService registration:
builder.Services.AddSingleton<IAuditService, AuditService>(); // Task 11.4 — must follow IGroupService
Add using ThalabusInterfaces.Services; and using ThalabusCore.Services; if not already present.
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | CreateDossier completes successfully |
Line [RESOURCE_LIFECYCLE.ResourceCreated] ... resourceId=<newId> present in thalabus_AUDIT.log |
| T2 | MoveResource moves a resource to a dossier with a different owner |
Two lines in thalabus_AUDIT.log: one ResourceMoved, one OwnershipChanged |
| T3 | MoveResource moves a resource to a dossier with the same owner |
Exactly one audit line: ResourceMoved only |
| T4 | AddPermission completes |
Line [RESOURCE_PERMISSION.PermissionGranted] ... resourceId=... targetId=... metadata={"role":...} in log |
| T5 | AuditService.Log write throws internally |
CreateDossier still returns the new resource; no exception propagates |
| T6 | GroupService.AddMember completes |
Line [USER_MANAGEMENT.GroupMembershipChanged] ... metadata={"action":"added"} in log |
Acceptance Criteria
- T1, T2, T4, T6: correct lines present in thalabus_AUDIT.log after the operation.
- T3: exactly one audit line emitted.
- T5: no exception propagates to the caller; resource was created.
Implementation of these tests
Unit tests with a mock IAuditService (verify Log is called with correct arguments) for T1–T4, T6. Unit test with a deliberately broken DiagLog (or a no-op AuditService that throws) for T5.
6. Post-Implementation
What changed: IAuditService is injected into AclService, GroupService, and ResourceService. Confirmed call sites: AclService.cs (AccessDenied, SuperAdminAccessUsed, PermissionGranted ×2, PermissionRevoked, SharingActionPerformed), GroupService.cs (GroupMembershipChanged ×2, plus a GroupDeleted event not listed in the original scope table), and ResourceService.cs (ResourceCreated ×2, ResourceMoved, OwnershipChanged ×2, ResourceRenamed ×2, ResourceDeleted, ResourceRestored, ResourceProtected, ResourceUnprotected).
Test results: Wiring confirmed by code review; unit-test coverage for each call site was not verified individually in this pass.
Note:
GroupService.csalso emits aGroupDeletedaudit event, which is not listed in this task's event table above — the scope table should be extended to include it.
Task 12 — ACL Enforcement in ResourceService
Wire IAclService.CheckAccess into all user-facing operations on ResourceService. Enforcement lives at the service layer — ResourceService is the single entry point for resource mutation from the UI and other services; the repository remains a pure data-access layer with no permission logic.
| # | Task | Status |
|---|---|---|
| 12.1 | Enforce ACL on read operations in ResourceService (GetResource, GetChildren, GetAllDossiers) |
implemented |
| 12.2 | Enforce ACL on create operations in ResourceService (CreateDossier, CreateDocument) |
implemented |
| 12.3 | Enforce ACL on rename operations in ResourceService (RenameResource) |
implemented |
| 12.4 | Enforce ACL on delete / restore operations in ResourceService (DeleteResource, RestoreResource) |
implemented |
| 12.5 | Enforce ACL on protection operations in ResourceService (SetProtected, Unprotect) |
implemented |
| 12.6 | Enforce ACL on move / ownership operations in ResourceService (MoveResource, TransferOwnership) |
implemented |
| 12.7 | Enforce ACL on permission management in AclService (AddPermission, RemovePermission) |
implemented |
Task 12.1 — Detail
1. Task Overview
Short Description
Add an effectiveUserId parameter to the three read-facing methods in IResourceService / ResourceService and enforce Permission.Read via IAclService.CheckAccess before returning data.
Objective / Expected Result
- GetResource(resourceId, effectiveUserId) throws UnauthorizedAccessException when the effective user lacks READ on that resource.
- GetChildren(parentId, effectiveUserId) throws when the effective user lacks READ on the parent dossier (children inherit through the parent).
- GetAllDossiers(effectiveUserId) returns only dossiers the effective user can read (used by UI for move-target selection).
- Internal service-to-service calls (e.g. TransferOwnership calling GetResource for a system lookup) pass effectiveUserId: null, which skips the ACL check.
2. Context & Scope
Background / Reason for Change
Currently GetResource, GetChildren, and GetAllDossiers have no effectiveUserId and return data to any caller with no access gate. Any user can retrieve any dossier or document by ID.
In Scope
| Method | Signature change | Permission | Failure behaviour |
|---|---|---|---|
GetResource(string resourceId, string? effectiveUserId = null) |
add optional effectiveUserId |
READ on resourceId |
throw UnauthorizedAccessException |
GetChildren(string parentId, string? effectiveUserId = null) |
add optional effectiveUserId |
READ on parentId |
throw UnauthorizedAccessException |
GetAllDossiers(string? effectiveUserId = null) |
add optional effectiveUserId |
READ per dossier |
silently filter inaccessible dossiers from result |
effectiveUserId = null means a system/internal call — the ACL check is skipped, preserving backward compatibility with all existing internal callers inside ResourceService itself.
Out of Scope
- Write and mutation operations — covered by Tasks 12.2–12.6.
- GetResource / GetChildren calls inside ResourceService's own mutation methods (e.g. the GetResource(resourceId) call inside TransferOwnership) — these are internal lookups; pass effectiveUserId: null explicitly.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusInterfaces/Services/IResourceService.cs |
Add optional string? effectiveUserId = null to 3 method signatures |
ThalabusCore/Services/ResourceService.cs |
Add CheckAccess guard to the 3 methods |
| Blazor components calling these methods | Pass EffectiveUserId (from the page-level user selector) as effectiveUserId |
Dependencies
| Task | Reason |
|---|---|
| Task 10.2 | IAclService.CheckAccess must be implemented |
| Task 11.4 | IAuditService wired into AclService (so AccessDenied events are emitted automatically) |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Internal callers break when effectiveUserId becomes required |
Use optional parameter (string? effectiveUserId = null); internal callers implicitly pass null |
GetAllDossiers silently drops dossiers — UI move-target dropdown shows fewer options |
This is correct ACL behaviour; document in XML comment that the result is effective user-filtered |
GetChildren checks the parent dossier only; a child that is individually isProtected with tighter ACL may still appear |
Acceptable at this stage — per-resource protection is handled when that specific resource is accessed directly via GetResource |
4. Implementation Plan
// IResourceService.cs — updated signatures
Task<ResourceItem?> GetResource(string resourceId, string? effectiveUserId = null);
Task<IEnumerable<ResourceItem>> GetChildren(string parentId, string? effectiveUserId = null);
Task<IEnumerable<INeo4jDossier>> GetAllDossiers(string? effectiveUserId = null);
// ResourceService.cs — guard pattern
public async Task<ResourceItem?> GetResource(string resourceId, string? effectiveUserId = null) {
if (effectiveUserId != null && !await AclService.CheckAccess(effectiveUserId, resourceId, Permission.Read))
throw new UnauthorizedAccessException(
$"Effective User '{effectiveUserId}' does not have Read permission on resource '{resourceId}'.");
// ... existing implementation unchanged ...
}
public async Task<IEnumerable<ResourceItem>> GetChildren(string parentId, string? effectiveUserId = null) {
if (effectiveUserId != null && !await AclService.CheckAccess(effectiveUserId, parentId, Permission.Read))
throw new UnauthorizedAccessException(
$"Effective User '{effectiveUserId}' does not have Read permission on dossier '{parentId}'.");
// ... existing implementation unchanged ...
}
public async Task<IEnumerable<INeo4jDossier>> GetAllDossiers(string? effectiveUserId = null) {
var all = await DocumentRepository.GetDossiers(isSession: false);
var visible = all.Where(d => !d.IsDeleted);
if (effectiveUserId == null) return visible;
var result = new List<INeo4jDossier>();
foreach (var d in visible)
if (await AclService.CheckAccess(effectiveUserId, d.Id, Permission.Read))
result.Add(d);
return result;
}
Blazor call-site update (example — ACLResourcePanel):
AllDossiers = (await ResourceService.GetAllDossiers(EffectiveUserId)).OrderBy(d => d.Name).ToList();
5. Testing Strategy
| # | Scenario | Expected Result |
|---|---|---|
| T1 | GetResource(id, effectiveUserId) — effective user has READ |
Resource returned |
| T2 | GetResource(id, effectiveUserId) — effective user lacks READ |
UnauthorizedAccessException; AccessDenied in audit log |
| T3 | GetResource(id, null) — internal call |
Resource returned without ACL check |
| T4 | GetChildren(parentId, effectiveUserId) — effective user lacks READ on parent |
UnauthorizedAccessException |
| T5 | GetAllDossiers(effectiveUserId) — effective user has READ on 3 of 6 dossiers |
3 dossiers returned |
| T6 | GetAllDossiers(null) — internal/system call |
All non-deleted dossiers returned |
Acceptance Criteria
- T1, T3, T5, T6: correct data returned.
- T2, T4: UnauthorizedAccessException thrown; no data leaked.
6. Post-Implementation
Date: 2026-05-06
What changed:
- IResourceService.cs: GetResource, GetChildren, GetAllDossiers each gained an optional string? effectiveUserId = null parameter.
- ResourceService.cs: GetResource and GetChildren throw UnauthorizedAccessException when effectiveUserId != null and CheckAccess returns false. GetAllDossiers silently filters out dossiers the effective user cannot read when effectiveUserId != null; returns all when effectiveUserId == null (internal/system call).
Build result: ThalabusInterfaces and ThalabusCore both build with 0 errors. Full ThalabusWeb build blocked by file-lock (app running) — no CS errors detected.
Test results: Manual test via ACL test page pending; unit tests not yet written.
Task 12.2 — Detail
1. Task Overview
Short Description
Add effectiveUserId to CreateDossier and CreateDocument in IResourceService / ResourceService and enforce Permission.Add on the parent dossier before creating the new resource.
Objective / Expected Result
- A user who lacks ADD on the parent dossier cannot create a child dossier or document inside it.
2. Context & Scope
Background / Reason for Change
Both methods currently take ownerGroupId but no effectiveUserId for the requesting user. The check must be on the parent, because ADD is a folder-level permission (documents are not assignable targets for ADD).
In Scope
| Method | Signature change | Permission | Resource checked |
|---|---|---|---|
CreateDossier(parentId, name, ownerGroupId, type, effectiveUserId) |
add string effectiveUserId |
ADD |
parentId |
CreateDocument(parentId, name, ownerGroupId, effectiveUserId) |
add string effectiveUserId |
ADD |
parentId |
Out of Scope
- System-provisioned dossiers (type=root, type=user, type=session) — created by bootstrap/session services; these callers pass a dedicated system principal ID or will be updated separately.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusInterfaces/Services/IResourceService.cs |
Add string effectiveUserId to both method signatures |
ThalabusCore/Services/ResourceService.cs |
Add CheckAccess(effectiveUserId, parentId, Permission.Add) guard |
All callers (DocumentManagementService, Blazor components) |
Pass effectiveUserId |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
ownerGroupId ≠ effectiveUserId (e.g. service creates resource on behalf of a group) |
effectiveUserId is the requesting user ID; ownerGroupId is the resource owner group. They remain separate parameters |
| System callers don't have a user identity | Pass a system principal ID (e.g. "system") — IsTenantAdmin bypass applies if that principal has the capability |
4. Implementation Plan
public async Task<INeo4jDossier> CreateDossier(
string parentId, string name, string ownerGroupId,
DossierType type = DossierType.Visible, string effectiveUserId = "system") {
if (!await AclService.CheckAccess(effectiveUserId, parentId, Permission.Add))
throw new UnauthorizedAccessException(
$"Effective User '{effectiveUserId}' does not have Add permission on dossier '{parentId}'.");
// ... existing implementation unchanged ...
}
public async Task<INeo4jDocument> CreateDocument(
string parentId, string name, string ownerGroupId, string effectiveUserId = "system") {
if (!await AclService.CheckAccess(effectiveUserId, parentId, Permission.Add))
throw new UnauthorizedAccessException(
$"Effective User '{effectiveUserId}' does not have Add permission on dossier '{parentId}'.");
// ... existing implementation unchanged ...
}
5. Testing Strategy
| # | Scenario | Expected Result |
|---|---|---|
| T1 | CreateDossier(parentId, …, effectiveUserId) — effective user has ADD on parent |
Dossier created |
| T2 | CreateDossier(parentId, …, effectiveUserId) — effective user lacks ADD |
UnauthorizedAccessException |
| T3 | CreateDocument(parentId, …, effectiveUserId) — effective user has ADD on parent |
Document created |
| T4 | CreateDocument(parentId, …, effectiveUserId) — effective user lacks ADD |
UnauthorizedAccessException |
Acceptance Criteria — T1, T3 succeed; T2, T4 throw without creating any node.
6. Post-Implementation
Date: 2026-05-06
What changed:
- IResourceService.cs: CreateDossier gained string effectiveUserId = "system" as a 5th parameter (after DossierType type). CreateDocument gained string effectiveUserId = "system" as a 4th parameter.
- ResourceService.cs: Both methods throw UnauthorizedAccessException if CheckAccess(effectiveUserId, parentId, Permission.Add) returns false. Audit log call updated to use effectiveUserId instead of ownerGroupId as the effective user.
Build result: Clean — 0 CS errors.
Test results: Manual test via ACL test page pending; unit tests not yet written.
Task 12.3 — Detail
1. Task Overview
Short Description
Enforce Permission.Update on RenameResource. No signature change is required — the method already takes effectiveUserId. The guard is inserted before the mutation.
Objective / Expected Result
- A user who lacks UPDATE on a resource cannot rename it.
2. Context & Scope
In Scope
| Method | Permission | Resource checked |
|---|---|---|
RenameResource(resourceId, newName, effectiveUserId) |
UPDATE |
resourceId |
Out of Scope — All other mutations.
3. Impact Analysis
No interface or signature changes. Only ResourceService.cs is modified.
4. Implementation Plan
public async Task RenameResource(string resourceId, string newName, string effectiveUserId) {
if (string.IsNullOrWhiteSpace(newName))
throw new ArgumentException("New name cannot be empty.", nameof(newName));
if (!await AclService.CheckAccess(effectiveUserId, resourceId, Permission.Update))
throw new UnauthorizedAccessException(
$"Effective User '{effectiveUserId}' does not have Update permission on resource '{resourceId}'.");
// ... existing implementation unchanged ...
}
5. Testing Strategy
| # | Scenario | Expected Result |
|---|---|---|
| T1 | RenameResource(id, newName, effectiveUserId) — effective user has UPDATE |
Resource renamed |
| T2 | RenameResource(id, newName, effectiveUserId) — effective user lacks UPDATE |
UnauthorizedAccessException |
6. Post-Implementation
Date: 2026-05-06
What changed:
- ResourceService.cs: RenameResource throws UnauthorizedAccessException if CheckAccess(effectiveUserId, resourceId, Permission.Update) returns false. Guard is inserted after the whitespace validation, before any DB access.
Build result: Clean — 0 CS errors.
Test results: Manual test via ACL test page pending; unit tests not yet written.
Task 12.4 — Detail
1. Task Overview
Short Description
Enforce Permission.Delete on DeleteResource and RestoreResource. Both methods already take effectiveUserId. PurgeResource is a system-only operation and requires no user-level ACL check.
Objective / Expected Result
- A user who lacks DELETE on a resource cannot soft-delete or restore it.
- PurgeResource (called by the background purge cron task) remains ACL-free.
2. Context & Scope
In Scope
| Method | Permission | Resource checked |
|---|---|---|
DeleteResource(resourceId, effectiveUserId) |
DELETE |
resourceId |
RestoreResource(resourceId, effectiveUserId) |
DELETE |
resourceId (restoring requires the same authority as deleting) |
Out of Scope
- PurgeResource(resourceId) — system cron operation, no user identity; no ACL check.
4. Implementation Plan
public async Task DeleteResource(string resourceId, string effectiveUserId) {
if (!await AclService.CheckAccess(effectiveUserId, resourceId, Permission.Delete))
throw new UnauthorizedAccessException(
$"Effective User '{effectiveUserId}' does not have Delete permission on resource '{resourceId}'.");
// ... existing implementation unchanged ...
}
public async Task RestoreResource(string resourceId, string effectiveUserId) {
if (!await AclService.CheckAccess(effectiveUserId, resourceId, Permission.Delete))
throw new UnauthorizedAccessException(
$"Effective User '{effectiveUserId}' does not have Delete permission on resource '{resourceId}'.");
// ... existing implementation unchanged ...
}
5. Testing Strategy
| # | Scenario | Expected Result |
|---|---|---|
| T1 | DeleteResource(id, effectiveUserId) — effective user has DELETE |
Resource soft-deleted |
| T2 | DeleteResource(id, effectiveUserId) — effective user lacks DELETE |
UnauthorizedAccessException |
| T3 | RestoreResource(id, effectiveUserId) — effective user has DELETE |
Resource restored |
| T4 | RestoreResource(id, effectiveUserId) — effective user lacks DELETE |
UnauthorizedAccessException |
| T5 | PurgeResource(id) — no effectiveUserId |
Resource purged; no ACL check |
Acceptance Criteria — T1, T3, T5 succeed; T2, T4 throw.
6. Post-Implementation
Date: 2026-05-06
What changed:
- ResourceService.cs: DeleteResource and RestoreResource both throw UnauthorizedAccessException if CheckAccess(effectiveUserId, resourceId, Permission.Delete) returns false. Guard is the first operation in each method, before GetResource. PurgeResource is unchanged — no ACL check.
Build result: Clean — 0 CS errors.
Test results: Manual test via ACL test page pending; unit tests not yet written.
Task 12.5 — Detail
1. Task Overview
Short Description
Enforce Permission.Sharing on SetProtected and Unprotect. Both methods already take effectiveUserId. Per the ACL design: "The acting group must hold SHARING permission over the resource to protect / unprotect it."
Objective / Expected Result
- Only a user with SHARING on a resource can toggle its isProtected flag.
2. Context & Scope
In Scope
| Method | Permission | Resource checked |
|---|---|---|
SetProtected(resourceId, effectiveUserId) |
SHARING |
resourceId |
Unprotect(resourceId, effectiveUserId) |
SHARING |
resourceId |
4. Implementation Plan
public async Task SetProtected(string resourceId, string effectiveUserId) {
if (!await AclService.CheckAccess(effectiveUserId, resourceId, Permission.Sharing))
throw new UnauthorizedAccessException(
$"Effective User '{effectiveUserId}' does not have Sharing permission on resource '{resourceId}'.");
await SetProtectedFlag(resourceId, true);
await AuditService.Log(AuditEventType.ResourceProtected, effectiveUserId, resourceId: resourceId);
}
public async Task Unprotect(string resourceId, string effectiveUserId) {
if (!await AclService.CheckAccess(effectiveUserId, resourceId, Permission.Sharing))
throw new UnauthorizedAccessException(
$"Effective User '{effectiveUserId}' does not have Sharing permission on resource '{resourceId}'.");
await SetProtectedFlag(resourceId, false);
await AuditService.Log(AuditEventType.ResourceUnprotected, effectiveUserId, resourceId: resourceId);
}
5. Testing Strategy
| # | Scenario | Expected Result |
|---|---|---|
| T1 | SetProtected(id, effectiveUserId) — effective user has SHARING |
isProtected set to true |
| T2 | SetProtected(id, effectiveUserId) — effective user lacks SHARING |
UnauthorizedAccessException |
| T3 | Unprotect(id, effectiveUserId) — effective user has SHARING |
isProtected set to false |
| T4 | Unprotect(id, effectiveUserId) — effective user lacks SHARING |
UnauthorizedAccessException |
6. Post-Implementation
Date: 2026-05-06
What changed:
- ResourceService.cs: SetProtected and Unprotect both throw UnauthorizedAccessException if CheckAccess(effectiveUserId, resourceId, Permission.Sharing) returns false. Guard is inserted before SetProtectedFlag and the audit call.
Build result: Clean — 0 CS errors.
Test results: Manual test via ACL test page pending; unit tests not yet written.
Task 12.6 — Detail
1. Task Overview
Short Description
Harden MoveResource with a missing source-side UPDATE check, and enforce Permission.Ownership on TransferOwnership. Both methods already take effectiveUserId.
Objective / Expected Result
- Moving a resource requires UPDATE on the resource itself (to change its location) in addition to the existing ADD check on the destination dossier.
- Transferring ownership requires OWNERSHIP on the resource.
2. Context & Scope
Permissions by method:
| Method | Permission | Resource checked | Notes |
|---|---|---|---|
MoveResource(resourceId, newParentId, effectiveUserId) |
UPDATE |
resourceId |
In addition to existing ADD on newParentId |
TransferOwnership(resourceId, newOwnerGroupId, effectiveUserId) |
OWNERSHIP |
resourceId |
From ACL design: "To change ownership of a dossier, the user must hold OWNERSHIP on that resource" |
Why UPDATE for MoveResource?
Moving a resource modifies its parent relationship — this is a structural update to the resource's location. The ADD check on the destination already ensures the effective user can write into the target folder. The UPDATE check on the source ensures the effective user has authority over the resource being moved.
Why OWNERSHIP for TransferOwnership?
The OWNERSHIP permission specifically governs the ability to modify the owner of a resource. Per ACL design: "OWNERSHIP — Modify the owner of a folder."
3. Impact Analysis
No interface or signature changes. Only ResourceService.cs is modified.
4. Implementation Plan
public async Task MoveResource(string resourceId, string newParentId, string effectiveUserId) {
// ... existing guards (type check, deleted check, parent validation) ...
// Source-side UPDATE check (new — effective user must be able to modify the resource)
if (!await AclService.CheckAccess(effectiveUserId, resourceId, Permission.Update))
throw new UnauthorizedAccessException(
$"Effective User '{effectiveUserId}' does not have Update permission on resource '{resourceId}'.");
// Existing destination-side ADD check
if (!await AclService.CheckAccess(effectiveUserId, newParentId, Permission.Add))
throw new UnauthorizedAccessException(
$"Effective User '{effectiveUserId}' does not have Add permission on dossier '{newParentId}'.");
// ... rest of existing implementation unchanged ...
}
public async Task TransferOwnership(string resourceId, string newOwnerGroupId, string effectiveUserId) {
if (!await AclService.CheckAccess(effectiveUserId, resourceId, Permission.Ownership))
throw new UnauthorizedAccessException(
$"Effective User '{effectiveUserId}' does not have Ownership permission on resource '{resourceId}'.");
// ... existing implementation unchanged ...
}
5. Testing Strategy
| # | Scenario | Expected Result |
|---|---|---|
| T1 | MoveResource — effective user has UPDATE on source AND ADD on destination |
Resource moved |
| T2 | MoveResource — effective user has ADD on destination but lacks UPDATE on source |
UnauthorizedAccessException |
| T3 | MoveResource — effective user has UPDATE on source but lacks ADD on destination |
UnauthorizedAccessException (existing check) |
| T4 | TransferOwnership — effective user has OWNERSHIP on resource |
Ownership transferred |
| T5 | TransferOwnership — effective user lacks OWNERSHIP |
UnauthorizedAccessException |
Acceptance Criteria — T1, T4 succeed; T2, T3, T5 throw.
6. Post-Implementation
Date: 2026-05-06
What changed:
- ResourceService.cs: MoveResource now checks UPDATE on resourceId before the existing ADD check on newParentId. The old single bool canAdd variable was replaced by two sequential CheckAccess throws. TransferOwnership throws UnauthorizedAccessException if CheckAccess(effectiveUserId, resourceId, Permission.Ownership) returns false — guard is the very first operation.
Build result: Clean — 0 CS errors.
Test results: Manual test via ACL test page pending; unit tests not yet written.
Task 12.7 — Detail
1. Task Overview
Short Description
Enforce Permission.Sharing on AclService.AddPermission and AclService.RemovePermission. Both methods already take effectiveUserId. The check gates the ability to modify ACL grants on a resource — a user must hold SHARING on the target resource before they can grant or revoke permissions on it.
Objective / Expected Result
- A user who lacks SHARING on a resource cannot call AddPermission or RemovePermission on that resource.
- SetAcl (atomic replace-all) uses the same guard, since it internally performs the same operation at bulk.
2. Context & Scope
Background / Reason for Change
Currently AddPermission and RemovePermission in AclService execute unconditionally — any caller can grant or revoke any group's access on any resource. ACL design states that the SHARING permission is the authority that controls who can modify the ACL of a resource. Without this gate, a Viewer could escalate their own permissions by directly calling AddPermission.
In Scope
| Method | Permission | Resource checked |
|---|---|---|
AddPermission(entry, effectiveUserId) |
SHARING |
entry.ResourceId |
RemovePermission(resourceId, groupId, effectiveUserId) |
SHARING |
resourceId |
Out of Scope
- SetAcl — covered separately if needed; shares the same pattern.
- GetAcl — read-only listing; no mutation guard required at this stage.
- GetEffectivePermissions / CheckAccess — internal helpers; no effective user-facing guard needed.
3. Impact Analysis
Affected Components / Modules
| Component | Change |
|---|---|
ThalabusCore/Services/AclService.cs |
Add CheckAccess guard at the start of AddPermission and RemovePermission |
Dependencies
| Task | Reason |
|---|---|
| Task 10.2 | CheckAccess must be implemented (already done) |
| Task 12.5 | SHARING permission is the established gate for ACL-structure modifications (already enforced for SetProtected/Unprotect) |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Bootstrap / system callers don't have a SHARING grant |
Pass a system principal ID (e.g. "system") — IsTenantAdmin bypass applies if that principal's group holds tenant-admin capability |
Calling CheckAccess inside AclService causes recursion if CheckAccess itself calls AddPermission |
CheckAccess only reads (via GetEffectivePermissions) — no mutation; no recursion possible |
4. Implementation Plan
// AclService.cs — AddPermission
public async Task AddPermission(IAclEntry entry, string effectiveUserId) {
// Task 12.7 — SHARING permission required to grant access on a resource
if (!await CheckAccess(effectiveUserId, entry.ResourceId, Permission.Sharing))
throw new UnauthorizedAccessException(
$"Effective User '{effectiveUserId}' does not have Sharing permission on resource '{entry.ResourceId}'.");
// ... existing implementation unchanged ...
}
// AclService.cs — RemovePermission
public async Task RemovePermission(string resourceId, string groupId, string effectiveUserId) {
// Task 12.7 — SHARING permission required to revoke access on a resource
if (!await CheckAccess(effectiveUserId, resourceId, Permission.Sharing))
throw new UnauthorizedAccessException(
$"Effective User '{effectiveUserId}' does not have Sharing permission on resource '{resourceId}'.");
// ... existing implementation unchanged ...
}
5. Testing Strategy
| # | Scenario | Expected Result |
|---|---|---|
| T1 | AddPermission(entry, effectiveUserId) — effective user has SHARING on entry.ResourceId |
Permission granted |
| T2 | AddPermission(entry, effectiveUserId) — effective user lacks SHARING |
UnauthorizedAccessException; AccessDenied in audit log |
| T3 | RemovePermission(resourceId, groupId, effectiveUserId) — effective user has SHARING |
Permission revoked |
| T4 | RemovePermission(resourceId, groupId, effectiveUserId) — effective user lacks SHARING |
UnauthorizedAccessException |
| T5 | System call using a TenantAdmin group member | Bypass applies; operation succeeds without explicit SHARING grant |
Acceptance Criteria — T1, T3, T5 succeed; T2, T4 throw without modifying any graph relationships.
6. Post-Implementation
Date: 2026-05-06
What changed:
- ThalabusCore/Services/AclService.cs: AddPermission throws UnauthorizedAccessException if CheckAccess(effectiveUserId, entry.ResourceId, Permission.Sharing) returns false — guard is the first operation before any Neo4j access. RemovePermission throws UnauthorizedAccessException if CheckAccess(effectiveUserId, resourceId, Permission.Sharing) returns false — same position.
Build result: Clean — 0 CS errors (ThalabusCore build succeeded).
Test results: Manual test via ACL test page pending; unit tests not yet written.
Task 13 — Semantic Search with ACL Filtering
Integrate access control filtering into the existing vector search pipeline in PythonServer. The workflow follows a search-first, filter-second pattern: vector search retrieves a ranked batch of candidate documents by semantic relevance, then only documents the requesting user holds READ permission on are returned. If the filtered result set is smaller than the requested count, the next batch of candidates is fetched and filtered until the target is reached or the vector store is exhausted. The Neo4j graph remains the single source of truth for permissions; the vector store holds only embeddings and document IDs.
| # | Task | Status |
|---|---|---|
| 13.1 | Add effectiveUserId (caller identity) parameter to the semantic search endpoint |
implemented |
| 13.2 | Implement post-retrieval ACL filter — check each candidate document ID against the caller's READ permission |
implemented |
| 13.3 | Implement batch retry loop — fetch the next ranked batch when the filtered result count is below the requested size | implemented |
| 13.4 | Update the search response contract — return only ACL-approved documents | implemented |
| 13.5 | Enable SUPER_ADMIN bypass in vector search ACL filter (_acl_where_clause) |
implemented |
Task 13.1 — Detail
1. Task Overview
Short Description
Add an effective_user_id parameter (the Neo4j User.id of the requesting user) to RagInterface.thalabus_query, RagService.thalabus_query, and RagService._semantic_search. The parameter is currently absent; without it the ACL filter introduced in Task 13.2 has no identity to check permissions against. This is a pure signature extension — no permission-checking logic is added here.
Objective / Expected Result
- RagInterface.thalabus_query and RagService.thalabus_query accept a new optional effective_user_id: Optional[str] = None keyword argument.
- RagService._semantic_search accepts and forwards effective_user_id to the ACL filter stub (Task 13.2).
- When effective_user_id is None, the pipeline behaves identically to today (no ACL filtering applied — safe fallback for callers that do not yet supply the parameter).
- All existing call sites compile and pass their test suites without modification.
2. Context & Scope
Background / Reason for Change
ACL_Design.md — "Vector search – search first" — requires that the permission check is performed per candidate document using the caller's identity. RagService already carries a userid parameter on thalabus_query, but that field is used exclusively for dataset-creation attribution. A separate, clearly named effective_user_id parameter is needed so that Tasks 13.2 and 13.3 can consume it for access control without ambiguity.
In Scope
- Adding effective_user_id: Optional[str] = None to RagInterface.thalabus_query (abstract method).
- Adding effective_user_id: Optional[str] = None to RagService.thalabus_query (implementation).
- Adding effective_user_id: Optional[str] = None to RagService._semantic_search (private helper).
- Forwarding effective_user_id from thalabus_query → _semantic_search.
- Files: PythonServer/src/interfaces/rag_interface.py, PythonServer/src/services/rag_service.py.
Out of Scope
- Implementing the permission check itself — covered by Task 13.2.
- Implementing the batch retry loop — covered by Task 13.3.
- Any changes to API route handlers in src/api/ — the endpoint surface is not modified in this task.
- Any changes outside the PythonServer project.
3. Impact Analysis
Affected Components / Modules
| File | Change |
|---|---|
PythonServer/src/interfaces/rag_interface.py |
Add effective_user_id parameter to thalabus_query abstract signature |
PythonServer/src/services/rag_service.py |
Add effective_user_id to thalabus_query and _semantic_search; forward the value through the call chain |
Dependencies
| Task | Reason |
|---|---|
| Task 13.2 | Consumes effective_user_id to perform the actual permission check |
| Task 2.1 — User Entity Extension | effective_user_id corresponds to INeo4jUser.Id; the stable UUID must exist on user nodes before the value can be resolved at the call site |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Existing callers pass positional arguments and break when the signature changes | effective_user_id is keyword-only with a None default; no positional shifts occur |
effective_user_id = None silently bypasses ACL in production |
Task 13.2 will log a warning when effective_user_id is None and skip filtering; callers must be audited before going to production |
thalabus_query_analyser shares the same interface but does not need ACL filtering |
No change to thalabus_query_analyser — it is an analysis utility, not a user-facing search endpoint |
Backward Compatibility Considerations
Fully backward compatible. All new parameters default to None; existing call sites are unaffected.
4. Implementation Plan
Step 1 — Update RagInterface.thalabus_query
In PythonServer/src/interfaces/rag_interface.py, add effective_user_id: Optional[str] = None to the abstract thalabus_query signature after the existing userid parameter:
@abstractmethod
def thalabus_query(
self,
query: str,
ontology_id: Optional[str] = None,
subset_of_datasetids: Optional[List[str]] = None,
automatically_rerank: bool = True,
create_dataset: Optional[Union[bool, str]] = None,
session_id: Optional[str] = None,
userid: Optional[str] = None,
effective_user_id: Optional[str] = None, # NEW — ACL identity of the requesting user
limit_to_documents: Optional[List[str]] = None,
limit_to_dossier_id: Optional[str] = None,
return_documents: bool = True,
) -> Tuple[List[dict], Optional[List[str]]]:
Update the docstring to describe effective_user_id.
Step 2 — Update RagService.thalabus_query
Mirror the same signature change in PythonServer/src/services/rag_service.py. Forward effective_user_id to _semantic_search.
Step 3 — Update RagService._semantic_search
Add effective_user_id: Optional[str] = None to the private method. Log a WARNING when effective_user_id is None so that the gap is visible in logs during development.
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | Call thalabus_query without effective_user_id (existing call site) |
Executes normally; no exception; ACL filter skipped with a warning log |
| T2 | Call thalabus_query with a valid effective_user_id |
Parameter is forwarded to _semantic_search; visible in debug logs |
| T3 | Call thalabus_query with effective_user_id=None explicitly |
Same as T1 |
| T4 | Subclass RagInterface without overriding the new parameter |
Abstract method signature is satisfied; no runtime error |
Acceptance Criteria
- rag_interface.py and rag_service.py compile without errors after the change.
- All existing unit tests in PythonServer/src/services/unit_testing/rag_service_unit_test.py pass unchanged.
- A new test asserts that effective_user_id is forwarded through thalabus_query → _semantic_search.
Implementation of these tests
Add a test case to PythonServer/src/services/unit_testing/rag_service_unit_test.py that patches _semantic_search and verifies effective_user_id is passed correctly.
6. Post-Implementation
Placeholder — to be populated after implementation.
What changed: list of files modified / created
Test results: outcomes of T1–T4
Task 13.2 — Detail
1. Task Overview
Short Description
Embed ACL READ permission checking directly inside the three existing Neo4j vector search queries (_get_semantic_chapters, _get_semantic_paragraphs, _get_semantic_summaries) so that the vector search and the permission check are performed in a single round-trip per index. Unauthorised documents never reach Python — they are filtered by Neo4j before the result set is returned.
Objective / Expected Result
- When effective_user_id is provided, each of the three index queries returns only fragments whose parent document the effective user holds READ permission on, enforcing: direct GRANT on the document, inherited GRANT via ancestor dossiers (with isProtected break), soft-delete exclusion, and SUPER_ADMIN bypass.
- Zero Python-level document filtering is performed in this task — all access control logic lives in Cypher.
- When effective_user_id is None or ACL_ENFORCEMENT_ENABLED=False, the queries behave identically to today.
- No additional Neo4j round-trips are introduced compared to the current unfiltered flow.
2. Context & Scope
Background / Reason for Change
ACL_Design.md — step 2 of the "Vector search – search first" workflow — requires filtering search candidates by access control. Because the _get_semantic_* methods already pattern-match the Document node inline (chapters via (chapter)-[:PART_OF]->(doc:Document), summaries via (c)-[:PART_OF]->(doc:Document), paragraphs via paragraph.documentid property) and because Neo4j EXISTS {} subqueries can evaluate ACL traversals inside the same Cypher statement, the cleanest architecture is to extend these existing queries rather than add a second round-trip. This eliminates the network overhead of a separate Python-level filter call and keeps all permission logic on the database side where it belongs.
In Scope
- Adding effective_user_id: Optional[str] = None to _get_semantic_chapters, _get_semantic_paragraphs, _get_semantic_summaries.
- A private helper _acl_where_clause(doc_var: str) -> str that returns a reusable Cypher AND (...) block containing the permission EXISTS subqueries; inserted into each query when ACL_ENFORCEMENT_ENABLED=True and effective_user_id is provided.
- Adding the ACL_ENFORCEMENT_ENABLED feature flag in PythonServer/src/config/constants.py.
- Forwarding effective_user_id from _semantic_search to all three helper methods.
- Files: PythonServer/src/services/rag_service.py, PythonServer/src/config/constants.py.
Out of Scope
- Implementing SHARING, DELETE, ADD, UPDATE, or OWNERSHIP permission checks — only READ is relevant for search.
- Any Python-level post-filtering of fragment lists — this is the explicit design choice of this task.
- Modifying the Neo4j graph schema — all required relationships (MEMBER_OF, GRANT, CONTAINS) are expected to exist after Tasks 3–10.
- Batch retry behaviour when the filtered result set is too small — covered by Task 13.3.
- Any changes outside PythonServer.
3. Impact Analysis
Affected Components / Modules
| File | Change |
|---|---|
PythonServer/src/services/rag_service.py |
Add effective_user_id param to _get_semantic_chapters, _get_semantic_paragraphs, _get_semantic_summaries, _semantic_search; add _acl_where_clause helper |
PythonServer/src/config/constants.py |
New ACL_ENFORCEMENT_ENABLED flag |
Dependencies
| Task | Reason |
|---|---|
| Task 13.1 | effective_user_id must be present in _semantic_search before it can be forwarded to the helpers |
| Tasks 3–10 | Group, MEMBER_OF, GRANT, CONTAINS graph structure must exist in Neo4j |
| Task 5 — Dossier Entity Extension | isProtected and isDeleted flags must be present on Document nodes |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| ACL graph not yet populated (Tasks 3–10 not complete) | ACL_ENFORCEMENT_ENABLED=False bypasses the filter entirely; no GRANT traversal occurs |
| Effective User has no groups | EXISTS subquery returns false for all docs; empty result set returned; warning logged |
| Deep dossier inheritance causing slow traversal | Bound [:CONTAINS*] depth to 1..10; add a Neo4j index on Dossier.isProtected to speed up filtering |
isDeleted documents still present in the vector index |
Added as an explicit WHERE doc.isDeleted = false guard before the ACL check |
Paragraphs do not have a direct [:PART_OF]->(Document) edge — only the property paragraph.documentid |
Match the Document node from the property: MATCH (doc:Document {id: paragraph.documentid}) before the ACL WHERE clause |
| SUPER_ADMIN bypass pattern not yet finalised | Add a placeholder EXISTS clause with a clear // TODO Task 9 comment; bypass is blocked until Task 9 defines the exact graph pattern |
Backward Compatibility Considerations
Fully backward compatible. When effective_user_id is None or ACL_ENFORCEMENT_ENABLED=False, the Cypher strings are identical to today.
4. Implementation Plan
Step 1 — Add ACL_ENFORCEMENT_ENABLED feature flag
In PythonServer/src/config/constants.py:
ACL_ENFORCEMENT_ENABLED: bool = False # Set True once ACL graph (Tasks 3–10) is fully populated
Step 2 — Add _acl_where_clause helper
Add to RagService:
def _acl_where_clause(self, doc_var: str) -> str:
"""
Return a Cypher AND-block that enforces READ permission for $effectiveUserId on the
node bound to `doc_var`. Inserted verbatim into query strings when ACL is active.
The caller must include $effectiveUserId in the query parameters.
"""
return f"""
AND {doc_var}.isDeleted = false
AND (
// Direct [:GRANT:READ] relationship on the document
EXISTS {{
MATCH (u:User {{id: $effectiveUserId}})-[:MEMBER_OF]->(g:Group)
-[:`GRANT:READ`]->({doc_var})
}}
OR (
// Inherited [:GRANT:READ] via ancestor dossier — only when not protected
NOT {doc_var}.isProtected
AND EXISTS {{
MATCH (u:User {{id: $effectiveUserId}})-[:MEMBER_OF]->(g:Group)
-[:`GRANT:READ`]->(dos:Dossier)
-[:CONTAINS*1..10]->({doc_var})
WHERE NOT dos.isProtected
}}
)
// SUPER_ADMIN bypass — enabled in Task 13.5 once ACL graph (Tasks 3–10) is fully populated
// OR EXISTS {{ MATCH (u:User {{id: $effectiveUserId}})-[:MEMBER_OF]->(g:Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:Capability {{id: 'tenant-admin'}}) }}
)
"""
Step 3 — Update _get_semantic_chapters
Add effective_user_id: Optional[str] = None parameter. When effective_user_id is provided and ACL_ENFORCEMENT_ENABLED=True, insert a MATCH (doc:Document {id: chapter.documentid}) clause and append _acl_where_clause('doc') to the WHERE conditions. Pass effectiveUserId in the params dict.
Cypher pattern for the no-dataset-filter + ACL variant (illustrative):
CALL db.index.vector.queryNodes('typical_rag', $nearest_neighbours, $embedding)
YIELD node AS chapter, score
WHERE chapter.documentid IS NOT NULL
MATCH (doc:Document {id: chapter.documentid})
WHERE doc.isDeleted = false
AND (
EXISTS {
MATCH (u:User {id: $effectiveUserId})-[:MEMBER_OF]->(g:Group)
-[:`GRANT:READ`]->(doc)
}
OR (
NOT doc.isProtected
AND EXISTS {
MATCH (u:User {id: $effectiveUserId})-[:MEMBER_OF]->(g:Group)
-[:`GRANT:READ`]->(dos:Dossier)
-[:CONTAINS*1..10]->(doc)
WHERE NOT dos.isProtected
}
)
)
RETURN chapter.id AS fragmentid,
chapter.documentid AS documentid,
chapter.text AS text,
score AS raw_score
ORDER BY raw_score DESC
LIMIT $limit
Step 4 — Update _get_semantic_paragraphs
Same as Step 3. Paragraphs already carry paragraph.documentid as a property; match the Document node identically:
MATCH (doc:Document {id: paragraph.documentid})
then append _acl_where_clause('doc').
Step 5 — Update _get_semantic_summaries
The summary query already traverses to a Document node bound as d via MATCH (c)-[:PART_OF]->(d:Document). Append _acl_where_clause('d') to the existing WHERE block. No additional MATCH clause is needed.
Step 6 — Forward effective_user_id through _semantic_search
Add effective_user_id: Optional[str] = None to _semantic_search. In the ThreadPoolExecutor block, pass effective_user_id to each of the three futures:
chapter_future = executor.submit(self._get_semantic_chapters, search_embedding, subset_of_datasetids, effective_user_id)
paragraph_future = executor.submit(self._get_semantic_paragraphs, search_embedding, subset_of_datasetids, effective_user_id)
summary_future = executor.submit(self._get_semantic_summaries, search_embedding, subset_of_datasetids, effective_user_id)
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | effective_user_id=None — ACL clauses absent |
Query string identical to today; zero ACL-related Cypher keywords present |
| T2 | ACL_ENFORCEMENT_ENABLED=False |
Same as T1 even if effective_user_id is provided |
| T3 | Effective User has GRANT:READ directly on the document |
Fragment returned for that document |
| T4 | Effective User has GRANT:READ on a parent dossier (inherited, not protected) |
Fragment returned |
| T5 | Document is isProtected=true; effective user has only inherited grant |
Fragment excluded |
| T6 | Document is isDeleted=true |
Fragment excluded regardless of grants |
| T7 | Effective User has no groups | All EXISTS subqueries evaluate false; no fragments returned; warning logged |
| T8 | Neo4j query fails (connection error) | Empty list returned; error logged; no exception propagated |
| T9 | Chapters, paragraphs, and summaries all run ACL-aware queries in parallel | Three separate Cypher calls, each embedding the ACL clause; no extra round-trip |
Acceptance Criteria
- T1–T9 pass.
- A grep of rag_service.py after this task confirms no Python-level ACL document list filtering is present (no if doc_id in permitted_ids pattern).
- When ACL_ENFORCEMENT_ENABLED=False, the generated Cypher strings do not contain MEMBER_OF or GRANT.
- When ACL_ENFORCEMENT_ENABLED=True and effective_user_id is set, each of the three Cypher strings passed to execute_neo4j_query contains the ACL EXISTS block.
Implementation of these tests
Add test_acl_vector_queries.py in PythonServer/src/services/unit_testing/. Mock execute_neo4j_query to capture the Cypher string and params dict, then assert the presence or absence of the ACL block for each scenario. For permission logic (T3–T7), use an integration test against a test Neo4j instance seeded with controlled GRANT and MEMBER_OF relationships.
6. Post-Implementation
Placeholder — to be populated after implementation.
What changed: list of files modified / created
Test results: outcomes of T1–T9
Task 13.3 — Detail
1. Task Overview
Short Description
Wrap _semantic_search (which, after Task 13.2, returns only ACL-permitted fragments) in a batch-retry loop inside RagService. When the number of permitted documents returned is below the caller's requested count, the loop widens the candidate pool by increasing the vector index LIMIT and re-runs retrieval until the target is reached or the vector store is exhausted. Because ACL filtering is embedded inside Neo4j (Task 13.2), there is no separate filter step — the loop simply checks the count of distinct documentid values in the result.
Objective / Expected Result
- RagService.thalabus_query returns up to limit ACL-permitted documents per call.
- When the first batch of TOP_N_RAG candidates yields fewer than limit permitted results, the pipeline transparently fetches a larger batch and re-filters.
- The loop terminates when either the target count is met or the requested candidate pool exceeds the total number of available nodes in the vector index (exhaustion guard).
- Retry behaviour is configurable via constants; no retry occurs when effective_user_id is None (no ACL filter active).
2. Context & Scope
Background / Reason for Change
ACL_Design.md explicitly states: "What if top 100 results contains only unauthorized documents? → Fetch next batch." The current _semantic_search issues a single query with a fixed TOP_N_RAG = 150 limit. After ACL filtering (Task 13.2), the result set may be empty or too small if the top-ranked documents are all unauthorized. The batch-retry loop is the mitigation.
In Scope
- New private method RagService._semantic_search_with_acl(search_term, effective_user_id, target_count, subset_of_datasetids) that encapsulates the loop.
- Four new constants in PythonServer/src/config/constants.py:
- ACL_BATCH_INITIAL_LIMIT — first-pass candidate count (default: TOP_N_RAG = 150).
- ACL_BATCH_MAX_LIMIT — maximum candidate pool before giving up (default: 1500).
- ACL_BATCH_MULTIPLIER — factor by which the limit grows each retry (default: 3).
- ACL_BATCH_MAX_RETRIES — hard cap on loop iterations (default: 5).
- Updating _semantic_search to accept an optional limit_override parameter; when set, it is forwarded as the $limit parameter and scales ANN_NEIGHBOURS_* proportionally so the vector index pre-selects a matching candidate pool.
- File: PythonServer/src/services/rag_service.py and PythonServer/src/config/constants.py.
Out of Scope
- True cursor-based pagination of Neo4j vector index results — the existing vector query uses LIMIT N; retry is achieved by increasing N, not by offsetting.
- Changes to the public API contract of thalabus_query (handled by Tasks 13.1 and 13.4).
- Any changes outside PythonServer.
3. Impact Analysis
Affected Components / Modules
| File | Change |
|---|---|
PythonServer/src/services/rag_service.py |
New method _semantic_search_with_acl; _semantic_search accepts limit_override; thalabus_query calls the new wrapper when effective_user_id is set |
PythonServer/src/config/constants.py |
Three new ACL batch constants |
Dependencies
| Task | Reason |
|---|---|
| Task 13.1 | effective_user_id must be in the call chain |
| Task 13.2 | ACL filtering must be embedded in _semantic_search before the loop can rely on counting only permitted results |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
Infinite loop if ACL_BATCH_MAX_LIMIT is set too high |
Hard cap ACL_BATCH_MAX_LIMIT; add a loop iteration counter and break after a maximum of 5 retries regardless |
| Performance degradation on each retry (larger Neo4j scan) | Log retry count and latency at PERF level; alert if more than 2 retries are needed in production |
Effective User has READ on zero documents in the entire corpus |
Return empty list after exhaustion; do not loop forever |
target_count not supplied by the caller |
Default to RagService.TOP_N_RAG |
Backward Compatibility Considerations
When effective_user_id is None, thalabus_query calls _semantic_search directly as before; the new _semantic_search_with_acl wrapper is not invoked.
4. Implementation Plan
Step 1 — Add constants
In PythonServer/src/config/constants.py:
ACL_BATCH_INITIAL_LIMIT: int = 150 # matches TOP_N_RAG
ACL_BATCH_MAX_LIMIT: int = 1500
ACL_BATCH_MULTIPLIER: int = 3
ACL_BATCH_MAX_RETRIES: int = 5
Step 2 — Add limit_override to _semantic_search
Add an optional limit_override: Optional[int] = None parameter. When set, use it instead of the class-level TOP_N_* constants for all three index queries (chapters, paragraphs, summaries).
Step 3 — Implement _semantic_search_with_acl
Because _semantic_search already returns only ACL-permitted fragments (Task 13.2), the loop simply counts distinct documentid values in the result and re-calls with a larger limit if needed. No separate filter call is required.
def _semantic_search_with_acl(
self,
search_term: str,
effective_user_id: str,
target_count: int,
subset_of_datasetids: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
from src.config.constants import (
ACL_BATCH_INITIAL_LIMIT, ACL_BATCH_MAX_LIMIT,
ACL_BATCH_MULTIPLIER, ACL_BATCH_MAX_RETRIES,
)
current_limit = ACL_BATCH_INITIAL_LIMIT
attempt = 0
permitted_fragments: List[Dict[str, Any]] = []
while attempt < ACL_BATCH_MAX_RETRIES and current_limit <= ACL_BATCH_MAX_LIMIT:
attempt += 1
# _semantic_search returns only ACL-permitted fragments when effective_user_id is set (Task 13.2)
permitted_fragments = self._semantic_search(
search_term, subset_of_datasetids,
effective_user_id=effective_user_id, limit_override=current_limit
)
unique_permitted_docs = {f["documentid"] for f in permitted_fragments if f.get("documentid")}
log(INFO, f"{self._log_prefix}._sem_with_acl: attempt={attempt} limit={current_limit} "
f"permitted_docs={len(unique_permitted_docs)}")
if len(unique_permitted_docs) >= target_count:
return permitted_fragments
if current_limit >= ACL_BATCH_MAX_LIMIT:
log(WARNING, f"{self._log_prefix}._sem_with_acl: exhausted at limit={current_limit}")
return permitted_fragments
current_limit = min(current_limit * ACL_BATCH_MULTIPLIER, ACL_BATCH_MAX_LIMIT)
return permitted_fragments
Step 4 — Wire into thalabus_query
Replace the existing _semantic_search call inside thalabus_query with a conditional:
if effective_user_id:
fragments = self._semantic_search_with_acl(term, effective_user_id, target_count, subset_of_datasetids)
else:
fragments = self._semantic_search(term, subset_of_datasetids)
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | First batch yields enough permitted results | Loop exits after one iteration; single _semantic_search call |
| T2 | First batch all unauthorized; second batch has enough | Loop runs twice; returns results from wider batch |
| T3 | ACL_BATCH_MAX_RETRIES reached before target |
Returns best available permitted subset; logs warning |
| T4 | ACL_BATCH_MAX_LIMIT reached (exhaustion) |
Returns whatever is available; logs warning |
| T5 | effective_user_id=None — retry loop not entered |
_semantic_search called directly; original behaviour |
| T6 | All documents in corpus are unauthorized | Empty list returned; no infinite loop |
Acceptance Criteria
- T1–T6 pass.
- Loop iterates at most ACL_BATCH_MAX_RETRIES times regardless of ACL results.
- Retry count and latency appear in PERF-level logs.
- When effective_user_id=None, _semantic_search_with_acl is never called.
Implementation of these tests
Add test_semantic_search_with_acl.py in PythonServer/src/services/unit_testing/. Use unittest.mock.patch to mock _semantic_search (returns a controlled list of already-permitted fragments) and assert loop termination conditions for each scenario. Because ACL filtering is in Neo4j (Task 13.2), there is no _filter_by_acl to mock.
6. Post-Implementation
Placeholder — to be populated after implementation.
What changed: list of files modified / created
Test results: outcomes of T1–T6
Task 13.4 — Detail
1. Task Overview
Short Description
Update the thalabus_query response to guarantee that — when effective_user_id is provided — only ACL-permitted documents appear in the returned list. This task also aligns the RagInterface.thalabus_query docstring and the internal document-aggregation step in RagService so that permission-filtered fragments are the sole input to the aggregation, preventing unauthorized content from leaking through via fragment-to-document rollup.
Objective / Expected Result
- When effective_user_id is set, thalabus_query returns only documents for which the effective user holds READ permission.
- The existing (results, dataset_ids) return type is preserved; no breaking change to the call signature.
- results contains document-level dicts where every entry has been drawn exclusively from ACL-permitted fragments produced by _semantic_search_with_acl.
- When effective_user_id is None, the return value is identical to today (no filtering).
2. Context & Scope
Background / Reason for Change
Tasks 13.2 and 13.3 build the ACL filtering and retry infrastructure, but they operate at the fragment level (chapters, paragraphs, summaries). thalabus_query aggregates fragments into document-level results via a document-rollup step. Without explicitly ensuring that the aggregation input has already been ACL-filtered, a document could be included in the output if one of its fragments slipped through due to an edge case. This task closes that gap and makes the contract explicit in the interface docstring.
In Scope
- Ensuring thalabus_query passes only ACL-filtered fragments to the document-aggregation step when effective_user_id is set.
- Updating the RagInterface.thalabus_query docstring to document effective_user_id and the ACL guarantee.
- Updating the RagService.thalabus_query docstring accordingly.
- Files: PythonServer/src/interfaces/rag_interface.py, PythonServer/src/services/rag_service.py.
Out of Scope
- Changing the (List[dict], Optional[List[str]]) return type.
- Modifying the document-aggregation logic itself — only its input is changed.
- Exposing the ACL enforcement via a new HTTP endpoint — the existing endpoint surface is unchanged in this task.
- Any changes outside PythonServer.
3. Impact Analysis
Affected Components / Modules
| File | Change |
|---|---|
PythonServer/src/interfaces/rag_interface.py |
Update thalabus_query docstring to document effective_user_id and ACL guarantee |
PythonServer/src/services/rag_service.py |
Ensure the aggregation step receives only permitted fragments; update docstring |
Dependencies
| Task | Reason |
|---|---|
| Task 13.1 | effective_user_id parameter must exist in the signature |
| Task 13.2 | ACL filtering must be embedded in _semantic_search so that all fragments entering the aggregation step are already permitted |
| Task 13.3 | _semantic_search_with_acl must produce the filtered fragment list before aggregation |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| Fragment-to-document aggregation introduces documents not present in the permitted fragment list | After aggregation, add an explicit guard: results = [r for r in results if r.get("documentid") in permitted_doc_ids] |
Dataset created from search results (create_dataset=True) includes unauthorized documents |
Dataset creation happens after aggregation; because only permitted fragments reach aggregation, the dataset is inherently clean |
return_documents=False (raw fragment mode) bypasses aggregation |
In raw fragment mode the permitted fragment list is returned directly; no additional guard needed |
Backward Compatibility Considerations
No change when effective_user_id is None. Callers that already supply effective_user_id=None (explicit or by default) observe identical behaviour.
4. Implementation Plan
Step 1 — Guard the aggregation input in thalabus_query
In RagService.thalabus_query, locate the call to _semantic_search (or its replacement added in Task 13.3). After the fragment list is produced by _semantic_search_with_acl, capture the set of permitted document IDs:
if effective_user_id:
permitted_fragments = self._semantic_search_with_acl(term, effective_user_id, target_count, subset_of_datasetids)
permitted_doc_ids = {f["documentid"] for f in permitted_fragments if f.get("documentid")}
else:
permitted_fragments = self._semantic_search(term, subset_of_datasetids)
permitted_doc_ids = None # no ACL constraint
After the document-aggregation step, apply the post-aggregation guard when permitted_doc_ids is set:
if permitted_doc_ids is not None:
results = [r for r in results if r.get("documentid") in permitted_doc_ids]
Step 2 — Update docstrings
In RagInterface.thalabus_query and RagService.thalabus_query, document:
- effective_user_id — the Neo4j User.id of the requesting user; when provided, only documents the effective user holds READ permission on are included in the result.
- The ACL guarantee: "When effective_user_id is set, no unauthorized document appears in the returned list."
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | effective_user_id set; aggregation produces a doc not in permitted fragment set |
Post-aggregation guard removes the doc; it does not appear in results |
| T2 | effective_user_id set; all aggregated docs are permitted |
results unchanged by the guard |
| T3 | effective_user_id=None |
Guard not applied; results identical to current behaviour |
| T4 | return_documents=False; effective_user_id set |
Raw permitted fragments returned; no doc-level aggregation; all fragments are ACL-filtered |
| T5 | create_dataset=True; effective_user_id set |
Dataset contains only permitted document fragments |
Acceptance Criteria
- T1–T5 pass.
- Static analysis (grep) confirms no code path in thalabus_query can return a document whose documentid is not in permitted_doc_ids when effective_user_id is provided.
- The interface docstring explicitly states the ACL guarantee.
Implementation of these tests
Extend the tests in PythonServer/src/services/unit_testing/rag_service_unit_test.py. For T1, mock _semantic_search_with_acl to return fragments for doc A only, and mock the aggregation to produce docs A and B; assert only doc A appears in the final output.
6. Post-Implementation
What changed:
- PythonServer/src/services/rag_service.py:
- Added permitted_doc_ids capture from ACL-filtered fragments after _evaluate_thalabus_ast returns (when effective_user_id is set).
- Added post-aggregation guard after results.sort(...) that filters results to only entries whose id or documentid appears in permitted_doc_ids.
- Updated thalabus_query docstring to document the ACL guarantee.
- PythonServer/src/interfaces/rag_interface.py:
- Updated thalabus_query docstring: effective_user_id param now states the ACL guarantee explicitly; :return: documents that results contain only ACL-permitted entries when effective_user_id is set.
- PythonServer/src/services/unit_testing/test_acl_response_contract.py (created):
- T1–T5 unit tests using unittest.mock — no live Neo4j required.
Test results: T1 ✓ T2 ✓ T3 ✓ T4 ✓ T5 ✓ (all pass with mocked pipeline)
Task 13.5 — Detail
1. Task Overview
Short Description
Activate the SUPER_ADMIN bypass in the Python vector search ACL filter. The _acl_where_clause helper in RagService contains a commented-out Cypher fragment that allows a SUPER_ADMIN user to bypass ACL restrictions during semantic search. This task uncomments the bypass and verifies that SUPER_ADMIN users receive unrestricted search results.
Objective / Expected Result
- When effective_user_id belongs to a tenant group (identified by capability traversal (Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'})), _semantic_search_with_acl returns all semantically relevant documents regardless of isProtected flags or grant relationships.
- Non-SUPER_ADMIN behaviour is unchanged.
2. Context & Scope
Background / Reason for Change
The bypass was left as a comment in Task 13.2 pending full population of the ACL graph (Tasks 3–10). At the time Task 13.5 is reached in the implementation sequence, all prerequisites (tenant group node, [:MEMBER_OF] relationships, tenant-admin capability chain) are guaranteed to be in place.
In Scope
- Uncommenting the SUPER_ADMIN bypass OR clause in _acl_where_clause in PythonServer/src/services/rag_service.py.
- Removing the Task 13.5 reference comment and replacing it with a plain explanatory comment.
- Verifying with at least one integration test that a SUPER_ADMIN effective user receives results that a non-SUPER_ADMIN effective user with no grants would not.
Out of Scope
- Changes to the C# ACL stack.
- Changes to any method other than _acl_where_clause and its callers.
3. Impact Analysis
Affected Components / Modules
| File | Change |
|---|---|
PythonServer/src/services/rag_service.py |
Uncomment the SUPER_ADMIN bypass OR clause in _acl_where_clause |
Dependencies
| Task | Reason |
|---|---|
| Task 13.2 | _acl_where_clause must exist; SUPER_ADMIN bypass comment is defined there |
| Tasks 3–10 | SUPER_ADMIN Group node with tenant-admin capability and [:MEMBER_OF] relationships must be present in the Neo4j graph |
Risks & Edge Cases
| Risk | Mitigation |
|---|---|
| SUPER_ADMIN bypass inadvertently applied when ACL graph is incomplete | Task ordering guarantees Tasks 3–10 are complete before Task 13.5 |
tenant-admin capability chain missing from group |
Task 0.2 provisions this chain at bootstrap; verify with a schema check test |
4. Implementation Plan
Step 1 — Uncomment the bypass in _acl_where_clause
In PythonServer/src/services/rag_service.py, in the _acl_where_clause method, change:
# SUPER_ADMIN bypass — enabled in Task 13.5 once ACL graph (Tasks 3–10) is fully populated
# OR EXISTS {{ MATCH (u:User {{id: $effectiveUserId}})-[:MEMBER_OF]->(g:Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:Capability {{id: 'tenant-admin'}}) }}
to:
# SUPER_ADMIN bypass: members of any group holding the 'tenant-admin' capability can access all documents
OR EXISTS {{ MATCH (u:User {{id: $effectiveUserId}})-[:MEMBER_OF]->(g:Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:Capability {{id: 'tenant-admin'}}) }}
Step 2 — Integration test
Add a test in PythonServer/src/services/unit_testing/ that:
1. Mocks the Neo4j query to simulate a SUPER_ADMIN user (group holds tenant-admin capability).
2. Mocks _semantic_search to return a document with isProtected=true and no explicit grant for the effective user.
3. Asserts the document appears in the results (bypass active).
4. Asserts the same document does NOT appear for a non-SUPER_ADMIN effective user with no grants.
5. Testing Strategy
Test Cases / Scenarios
| # | Scenario | Expected Result |
|---|---|---|
| T1 | effective_user_id belongs to tenant group; target doc is isProtected=true with no direct grant |
Doc returned |
| T2 | effective_user_id is a regular user with no grants on a protected doc |
Doc not returned |
| T3 | effective_user_id is a regular user with a valid [:GRANT:READ] grant |
Doc returned (normal ACL path) |
| T4 | effective_user_id=None |
Bypass not applied; unrestricted results as before |
Acceptance Criteria
- T1–T4 pass.
- The _acl_where_clause helper no longer contains any commented-out code for the SUPER_ADMIN bypass.
Implementation of these tests
Add test_super_admin_acl_bypass.py in PythonServer/src/services/unit_testing/. Mock the Neo4j driver to return controlled group membership data.
6. Post-Implementation
What changed:
- PythonServer/src/services/rag_service.py:
- _acl_where_clause: SUPER_ADMIN bypass OR clause uncommented and made live Cypher.
- Docstring updated: placeholder text replaced with description of the active bypass.
- PythonServer/src/services/unit_testing/test_super_admin_acl_bypass.py (created):
- T1 (3 assertions): _acl_where_clause output contains a non-commented HAS_CAPABILITY OR clause.
- T2 (3 assertions): Cypher reaching Neo4j via _get_semantic_chapters/paragraphs/summaries carries HAS_CAPABILITY / tenant-admin when ACL is active.
- T3 (3 assertions): Fragments returned by Neo4j for a SUPER_ADMIN / granted / no-grant effective user flow correctly through the Python layer.
- T4 (2 assertions): effective_user_id=None and ACL_ENFORCEMENT_ENABLED=False suppress the bypass clause entirely.
Test results: T1 ✓ T2 ✓ T3 ✓ T4 ✓ (all pass with mocked pipeline)
Implementation Order
This section is derived mechanically from the Dependencies tables embedded in each task's detail section. Tasks within the same phase have no interdependency and can be executed in parallel. A phase must not start until every task in all preceding phases is complete.
Notes Before You Start
| Note | Detail |
|---|---|
| Tasks 0.1 and 0.2 run first, implemented last | They are the first things to execute at application startup, but their implementation depends on late-phase infrastructure: all of Task 5 must be complete for 0.1; Tasks 3.1 and 3.3 must be complete for 0.2. |
| Task 8 is a long serial chain | ResourceService subtasks 8.2 → 8.3 → … → 8.10.1 form a strict sequential pipeline (10 steps). No parallelism is possible within this chain. |
| Python ACL integration (Task 13) is last | It requires the entire C# ACL stack (Tasks 3–10) to be complete before ACL-filtered semantic search can be meaningfully implemented. |
Key Dependency Chains
The longest (critical-path) chains through the task graph:
Relationship rename : 1.3 ──► 1.1 / 1.2 ──► 7.1
User entity : 2.1 ──► 2.2 ──► 2.3 ─────────────────────────────► 2.4
▲
Group entity : 3.1 ──► 3.3 ──► 3.4 ──► 3.4.1 │
└────► 3.5 ──► 4.1 ──► 4.2 ──► 4.3 ──────┘
Dossier entity : 5.1 ──► 5.2 ──► 5.3 ──► 5.4 ──► 0.1
Permission / ACL : 9.1 ──► 9.2 ──► 9.3 ──► 10.3 ──► 10.4 ──► 10.5
└──────────────────────► 10.7 ──► 10.7.1 ──► 12.1 ──► 12.2 ──► 12.5 ──► 12.6
Resource service : 8.1 ──► 8.2 ──► 8.3 ──► 8.4 ──► 8.5 ──► 8.6 ──► 8.7 ──► 8.8 ──► 8.9 ──► 8.10 ──► 8.10.1
Audit + Python ACL : 11.1 ──► 11.2 ──► 11.3 ──► 11.4 (needs 8.10.1)
2.1 ──► 13.1 ──► 13.2 ──► 13.3 ──► 13.4 (needs Tasks 3–10 complete)
Phase 1 — Foundation: Interfaces, Enums & DB Migration Script
No prerequisites. All 10 tasks are fully independent.
| Task | Short Description |
|---|---|
| 1.3 | Write one-time Neo4j Cypher migration script renaming legacy [:OWNS] relationships |
| 2.1 | Add Id to INeo4jUser |
| 3.1 | Define INeo4jGroup interface (Id, Name, IsUserGroup) |
| 3.2 | Define IGroupService interface |
| 5.1 | Add ACL fields to INeo4jDossier interface (isProtected, isDeleted, deletedAt, deletedBy, updatedAt) |
| 6.1 | Add ACL fields to INeo4jDocument interface (isProtected, isDeleted, deletedAt, deletedBy, updatedAt) |
| 8.1 | Define IResourceService interface |
| 9.1 | Define Permission enum (Read, Sharing, Delete, Add, Update, Ownership) |
| 10.1 | Define IAclService interface |
| 11.1 | Define AuditEventType enum (16 event types) |
Phase 2 — Entity Implementations & First Migrations
Requires Phase 1.
| Task | Short Description | Key Prerequisites |
|---|---|---|
| 1.1 | Rename [:OWNS]→[:CONTAINS] in Neo4jDocumentRepository.cs |
1.3 |
| 1.2 | Rename [:OWNS]→[:HAS_DOSSIER] in Neo4jDocumentRepository.cs |
1.3 |
| 2.2 | Implement new INeo4jUser properties in Neo4jUser; persist via Neo4jUsersManagement |
2.1 |
| 3.3 | Implement Neo4jGroup entity (reads id, name, isUserGroup from INode) |
3.1 |
| 5.2 | Migrate DossierType enum to root / user / session / visible |
5.1 |
| 6.2 | Implement Neo4jDocument with new ACL properties |
6.1 |
| 9.2 | Define RoleType enum and AclRoles static class |
9.1 |
| 10.2 | Implement AclService.CheckAccess |
10.1, 9.1 |
| 10.6 | Implement AclService.IsTenantAdmin private helper |
0.2 |
| 11.2 | Define IAuditService interface |
11.1 |
| 13.1 | Add effective_user_id parameter to Python RagInterface.thalabus_query |
2.1 |
Phase 3 — Service Foundations, Graph Structure & SuperAdmin Bootstrap
Requires Phase 2.
| Task | Short Description | Key Prerequisites |
|---|---|---|
| 0.2 | Implement SuperAdmin Group node bootstrap step in BootstrapService.Init() |
3.1, 3.3 |
| 2.3 | Neo4j migration to backfill id on existing User nodes |
2.2 |
| 3.4 | Implement GroupService (all five IGroupService methods) |
3.2, 3.3 |
| 3.5 (TBD) | Neo4j Group schema migration + Group.id uniqueness constraint |
3.1, 3.3 |
| 5.3 | Implement Neo4jDossier with new ACL properties |
5.1, 5.2 |
| 6.3 | Neo4j migration to backfill document ACL fields on existing Document nodes |
6.1, 6.2 |
| 7.1 | Update INeo4jSession / Neo4jSession to reflect [:HAS_DOSSIER] semantics |
1.2 |
| 9.3 | Define IAclEntry interface and AclEntry record |
9.1, 9.2 |
| 11.3 | Implement AuditService.Log (writes to thalabus_AUDIT.log) |
11.1, 11.2 |
Phase 4 — Service Wiring, ACL Mutations & Resource Service Start
Requires Phase 3.
| Task | Short Description | Key Prerequisites |
|---|---|---|
| 3.4.1 | Register IGroupService in Program.cs |
3.4 |
| 4.1 | Implement hidden system Group node creation (step 1 of ProvisionHiddenGroupAndDossierForUser) |
3.1, 3.3, 3.5, 2.1 |
| 5.4 | Neo4j migration to backfill isProtected, isDeleted, updatedAt on existing Dossier nodes |
5.2, 5.3 |
| 7.2 | Implement cascade purge on session deletion (hard-delete [:HAS_DOSSIER] dossier and all contents) |
7.1 |
| 10.3 | Implement AclService.GetEffectivePermissions |
10.2, 9.3, 3.2 |
| 10.7 | Implement AclService ACL mutation methods (GetAcl, SetAcl, AddPermission, RemovePermission) |
9.3, 9.2 |
| 8.2 | Implement ResourceService.CreateDossier |
8.1, 5.3, 6.2 |
Phase 5 — Full Dossier Schema, Permission Graph & Enforcement Scaffolding
Requires Phase 4.
| Task | Short Description | Key Prerequisites |
|---|---|---|
| 4.2 | Implement hidden user Dossier node creation (step 2 of ProvisionHiddenGroupAndDossierForUser) |
4.1 |
| 9.4 | Neo4j migration to create [:GRANT:<PERMISSION>] relationships from existing ownership |
3.5, 5.4, 6.3, 9.1 |
| 10.4 | Implement AclService.GetInheritedPermissions (ancestor walk; stops at protected node) |
10.3 |
| 10.7.1 | Register IAclService in Program.cs |
10.7, 3.4.1 |
| 8.3 | Implement ResourceService.createDocument |
8.2 |
| 12.3 | Enforce ACL on dossier read operations (GetDossier, GetDossiers, GetDossiersCount) |
10.2, 3.4.1 |
Phase 6 — Root Dossier Bootstrap, Protection Groundwork & First Enforcement
Requires Phase 5.
| Task | Short Description | Key Prerequisites |
|---|---|---|
| 0.1 | Implement root Dossier node bootstrap step in BootstrapService.Init() |
Task 5 complete (5.1–5.4) |
| 10.5 | Verify isProtected inheritance-break logic across GetEffectivePermissions and GetInheritedPermissions | 10.3, 10.4 |
| 8.4 | Implement ResourceService.moveResource | 8.3 |
| 12.1 | Enforce ACL READ on document read operations in Neo4jDocumentRepository (8 methods) | 10.2, 10.7.1, 9.1 |
| 12.4 | Enforce ACL on dossier write/delete operations | 10.2, 12.3 |
Phase 7 — User Provisioning Complete & Document Write Enforcement
Requires Phase 6.
| Task | Short Description | Key Prerequisites |
|---|---|---|
| 4.4 | Migrate existing users — backfill hidden group and dossier for all pre-existing accounts | 4.1, 4.2 |
| 2.4 | Wire IGroupService.ProvisionHiddenGroupAndDossierForUser into UserService.AddUser |
2.2, 3.4, 4.1, 4.2 |
| 8.5 | Implement ResourceService.renameResource |
8.4 |
| 12.2 | Enforce ACL on document write/delete operations in Neo4jDocumentRepository (7 methods) |
10.2, 12.1 |
Phase 8 — Resource Queries & Full Enforcement Layer
Requires Phase 7.
| Task | Short Description | Key Prerequisites |
|---|---|---|
| 8.6 | Implement ResourceService.getResource and getChildren |
8.5 |
| 12.5 | Enforce ACL on session-dossier linking and linked document adds | 10.2, 12.2 |
Phase 9 — Protection Logic & SuperAdmin Audit Emission
Requires Phase 8.
| Task | Short Description | Key Prerequisites |
|---|---|---|
| 8.7 | Implement ResourceService.setProtected / unprotect (requires SHARING permission) |
8.6, 9.1 |
| 12.6 | Emit SuperAdminAccessUsed / AccessDenied audit events; annotate system-exempt methods |
11.3, 12.1–12.5, 10.6 |
Phase 10 — Resource Lifecycle (Strictly Serial)
Requires Phase 9. Each step depends on the previous one — no parallelism is possible.
| Task | Short Description | Key Prerequisites |
|---|---|---|
| 8.8 | Implement ResourceService.deleteResource (soft delete, immediate inaccessibility) |
8.7 |
| 8.9 | Implement ResourceService.purgeResource (hard delete, unrecoverable) |
8.8 |
| 8.10 | Implement async purge cron task (hard-deletes soft-deleted resources after TTL) | 8.9 |
| 8.10.1 | Register IResourceService in Program.cs |
8.10 |
Phase 11 — Audit Wiring & Python ACL Integration
Requires Phase 10. Within this phase: 11.4 and 13.2 are independent and can run in parallel; 13.3 and 13.4 are serial continuations of 13.2.
| Task | Short Description | Key Prerequisites |
|---|---|---|
| 11.4 | Wire IAuditService.Log into ResourceService, AclService, GroupService (13 event points) |
11.3, 8.10.1, 10.7, 3.4 |
| 13.2 | Embed ACL READ filter into Python semantic search Cypher (_get_semantic_chapters, etc.) |
13.1, Tasks 3–10 complete |
| 13.3 | Implement batch-retry loop in _semantic_search_with_acl |
13.2 |
| 13.4 | Post-aggregation guard: ensure only ACL-permitted documents appear in thalabus_query results |
13.3 |
| 13.5 | Enable SUPER_ADMIN bypass in vector search ACL filter (_acl_where_clause) |
13.2 |
Quick-Reference: All 66 Tasks in Implementation Order
| Phase | Tasks (parallel within phase unless noted) |
|---|---|
| 1 | 1.3, 2.1, 3.1, 3.2, 5.1, 6.1, 8.1, 9.1, 10.1, 11.1 |
| 2 | 1.1, 1.2, 2.2, 3.3, 5.2, 6.2, 9.2, 10.2, 10.6, 11.2, 13.1 |
| 3 | 0.2, 2.3, 3.4, 3.5, 5.3, 6.3, 7.1, 9.3, 11.3 |
| 4 | 3.4.1, 4.1, 5.4, 7.2, 10.3, 10.7, 8.2 |
| 5 | 4.2, 9.4, 10.4, 10.7.1, 8.3, 12.3 |
| 6 | 0.1, 4.3, 10.5, 8.4, 12.1, 12.4 |
| 7 | 4.4, 2.4, 8.5, 12.2 |
| 8 | 8.6, 12.5 |
| 9 | 8.7, 12.6 |
| 10 | 8.8 → 8.9 → 8.10 → 8.10.1 (serial) |
| 11 | 11.4 ∥ 13.2 → 13.3 → 13.4 → 13.5 |
| #### 6. Post-Implementation |
Date: 2026-04-30
What changed:
- Created migration script Neo4jORM/migrations/008_group_and_membership.cypher.
- Adds uniqueness constraint on Group.id and index on Group.name.
Test results:
- Migration script created and ready to run. Idempotency ensured by IF NOT EXISTS clauses.