feat: Implement shared scopes with role enforcement (#90)
This commit is contained in:
parent
517abde4a1
commit
8acfa7f269
13 changed files with 4868 additions and 485 deletions
|
|
@ -21,6 +21,7 @@ class CrdtSyncEndpoint extends Endpoint {
|
|||
userId: UuidValue.withValidation(session.authenticated!.userIdentifier),
|
||||
inbound: changes,
|
||||
once: once,
|
||||
mode: CrdtSyncPeerMode.authoritative,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ class: CrdtMergeChange
|
|||
sealed: true
|
||||
extends: BaseHlc
|
||||
fields:
|
||||
### The scope this change belongs to.
|
||||
uuidScopeId: UuidValue
|
||||
|
||||
### The table receiving the change.
|
||||
tableName: String
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
### CRDT nodes table.
|
||||
###
|
||||
### This table stores all nodes that have been seen.
|
||||
### This table stores stable CRDT replica identities that have been seen.
|
||||
class: CrdtNode
|
||||
table: crdt_nodes
|
||||
database: all
|
||||
fields:
|
||||
### The scope that owns the node.
|
||||
scope: CrdtScope?, relation(name=scope_nodes, onDelete=Cascade)
|
||||
|
||||
### Unique identifier for the node.
|
||||
### Stable unique identifier for the replica/database install.
|
||||
uuidNodeId: UuidValue, default=random_v7, unique
|
||||
|
||||
### The last HLC timestamp when the node has synchronized.
|
||||
lastReceivedHlc: Hlc?
|
||||
### Last HLC generated by the local replica when this row is the current node.
|
||||
lastHlc: Hlc?
|
||||
|
|
|
|||
|
|
@ -12,5 +12,5 @@ fields:
|
|||
### The current node id of the scope.
|
||||
currentNode: CrdtNode?, relation(optional)
|
||||
|
||||
### The nodes associated with the scope.
|
||||
nodes: List<CrdtNode>?, relation(name=scope_nodes)
|
||||
### The nodes associated with the scope and their per-scope checkpoints.
|
||||
nodes: List<CrdtScopeNode>?, relation(name=scope_nodes)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
### Authorization membership for shared CRDT scopes.
|
||||
###
|
||||
### `database: all`, so the table exists on every node and membership resolves
|
||||
### with the same code on both ends. The server is the authoritative writer; a
|
||||
### client copy (once populated) is a read-only cache for UI and offline role
|
||||
### checks.
|
||||
###
|
||||
### Personal-scope membership is implicit: a user always belongs to the scope
|
||||
### whose UUID equals their auth user UUID. Rows in this table represent shared
|
||||
### scopes only.
|
||||
class: CrdtScopeMember
|
||||
table: crdt_scope_members
|
||||
database: all
|
||||
fields:
|
||||
### Shared scope this membership grants access to.
|
||||
scope: CrdtScope?, relation(onDelete=Cascade)
|
||||
|
||||
### Auth user UUID that may access the scope.
|
||||
userUuid: UuidValue
|
||||
|
||||
### CRDT access role for this shared-scope membership.
|
||||
role: CrdtScopeRole
|
||||
|
||||
indexes:
|
||||
### Leads with userUuid so the per-cycle membership resolution
|
||||
### (`WHERE userUuid = ?`) is index-covered, while still enforcing one row
|
||||
### per (scope, user). `isMember` (`WHERE userUuid AND scopeId`) is covered
|
||||
### too.
|
||||
crdt_scope_member_unique_idx:
|
||||
fields: userUuid, scopeId
|
||||
unique: true
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
### A CRDT node's participation and checkpoint state within one scope.
|
||||
class: CrdtScopeNode
|
||||
table: crdt_scope_nodes
|
||||
database: all
|
||||
fields:
|
||||
### Scope this participation row belongs to.
|
||||
scope: CrdtScope?, relation(name=scope_nodes, onDelete=Cascade)
|
||||
|
||||
### Stable replica identity participating in the scope.
|
||||
node: CrdtNode?, relation(onDelete=Cascade)
|
||||
|
||||
### Latest HLC from this node acknowledged for this scope.
|
||||
lastReceivedHlc: Hlc?
|
||||
|
||||
indexes:
|
||||
### Enforces one checkpoint row per (scope, node).
|
||||
crdt_scope_node_unique_idx:
|
||||
fields: scopeId, nodeId
|
||||
unique: true
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
### CRDT access role for a member of a shared scope.
|
||||
###
|
||||
### The role gates CRDT writes only. Reads remain membership-wide.
|
||||
enum: CrdtScopeRole
|
||||
serialized: byName
|
||||
values:
|
||||
- readOnly
|
||||
- readWrite
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
### Session handshake sent once by each peer before the first sync batch.
|
||||
### Session handshake sent once by each peer before any scope work.
|
||||
###
|
||||
### [localNodeId] identifies the sending peer. [syncTablesHash] is the
|
||||
### deterministic schema hash both sides must agree on before merging data.
|
||||
### [localNodeId] is this peer's persistent CRDT replica identity, shared
|
||||
### across all scopes. It is announced once on connect rather than per scope.
|
||||
### [syncTablesHash] is the deterministic schema hash both sides must agree on
|
||||
### before merging data.
|
||||
class: CrdtSyncConnect
|
||||
extends: CrdtSyncStreamEvent
|
||||
fields:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
### A single scope a peer announces in a [CrdtSyncScopeSet].
|
||||
###
|
||||
### Carries the scope UUID and the authoritative [role] the receiving user
|
||||
### holds in it. The personal scope is announced with [CrdtScopeRole.readWrite].
|
||||
class: CrdtScopeGrant
|
||||
fields:
|
||||
### Scope UUID being announced.
|
||||
uuidScopeId: UuidValue
|
||||
|
||||
### Authoritative CRDT access role of the receiving user in this scope.
|
||||
role: CrdtScopeRole
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
### Authoritative or informational scope set exchanged at the start of a cycle.
|
||||
###
|
||||
### The server sends the authoritative membership set (with roles). The client
|
||||
### sends an empty set; the server never widens access from follower-reported
|
||||
### state.
|
||||
class: CrdtSyncScopeSet
|
||||
extends: CrdtSyncStreamEvent
|
||||
fields:
|
||||
### Scopes this peer reports for the next sync cycle, each with the receiving
|
||||
### user's authoritative role.
|
||||
scopes: List<CrdtScopeGrant>
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
### One-time checkpoint sent by each peer after connect to exchange required
|
||||
### information for the sync session.
|
||||
### Per-scope checkpoint sent by each peer before the first visit to a scope.
|
||||
class: CrdtSyncSinceHlc
|
||||
extends: CrdtSyncStreamEvent
|
||||
fields:
|
||||
### Scope this checkpoint belongs to.
|
||||
uuidScopeId: UuidValue
|
||||
|
||||
### Per-node checkpoints that describe which changes the sender already has.
|
||||
###
|
||||
### Each checkpoint is an [Hlc] whose `nodeId` is the source node being
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@ serialized: byName
|
|||
values:
|
||||
- ownershipCollision
|
||||
- missingDomainRow
|
||||
- unauthorizedWrite
|
||||
|
|
|
|||
|
|
@ -0,0 +1,247 @@
|
|||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||
import 'package:serverpod_offline_sync_server/src/generated/protocol.dart' as server;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'test_tools/serverpod_test_tools.dart';
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
'Given a user with personal scope access and membership in one shared CRDT scope, '
|
||||
'and another user with membership in another scope,',
|
||||
(sessionBuilder, _) {
|
||||
final userUuid = const Uuid().v7obj();
|
||||
final otherUserUuid = const Uuid().v7obj();
|
||||
late Session session;
|
||||
late UuidValue sharedScopeUuid;
|
||||
late UuidValue otherScopeUuid;
|
||||
|
||||
setUp(() async {
|
||||
session = sessionBuilder.build();
|
||||
sharedScopeUuid = const Uuid().v7obj();
|
||||
otherScopeUuid = const Uuid().v7obj();
|
||||
|
||||
final sharedScope = await server.CrdtScope.db.insertRow(
|
||||
session,
|
||||
server.CrdtScope(uuidScopeId: sharedScopeUuid),
|
||||
);
|
||||
final otherScope = await server.CrdtScope.db.insertRow(
|
||||
session,
|
||||
server.CrdtScope(uuidScopeId: otherScopeUuid),
|
||||
);
|
||||
|
||||
await server.CrdtScopeMember.db.insertRow(
|
||||
session,
|
||||
server.CrdtScopeMember(
|
||||
scopeId: sharedScope.id!,
|
||||
userUuid: userUuid,
|
||||
role: server.CrdtScopeRole.readWrite,
|
||||
),
|
||||
);
|
||||
await server.CrdtScopeMember.db.insertRow(
|
||||
session,
|
||||
server.CrdtScopeMember(
|
||||
scopeId: otherScope.id!,
|
||||
userUuid: otherUserUuid,
|
||||
role: server.CrdtScopeRole.readOnly,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'when member scopes are resolved, '
|
||||
'then only the personal scope and the granted shared scope are included.',
|
||||
() async {
|
||||
final scopes = await CrdtScopeMembership.memberScopes(session, userUuid);
|
||||
final expectedScopes = [userUuid, sharedScopeUuid]
|
||||
..sort((a, b) => a.uuid.compareTo(b.uuid));
|
||||
|
||||
expect(scopes, expectedScopes);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when member grants are resolved, '
|
||||
'then the personal and granted shared scopes are returned with their roles.',
|
||||
() async {
|
||||
final grants = await CrdtScopeMembership.memberGrants(session, userUuid);
|
||||
|
||||
expect(
|
||||
{for (final grant in grants) grant.uuidScopeId: grant.role},
|
||||
{
|
||||
userUuid: CrdtScopeRole.readWrite,
|
||||
sharedScopeUuid: CrdtScopeRole.readWrite,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when a stray membership row grants readOnly access to the personal scope, '
|
||||
'then the personal grant remains readWrite.',
|
||||
() async {
|
||||
final personalScope = await server.CrdtScope.db.insertRow(
|
||||
session,
|
||||
server.CrdtScope(uuidScopeId: userUuid),
|
||||
);
|
||||
await server.CrdtScopeMember.db.insertRow(
|
||||
session,
|
||||
server.CrdtScopeMember(
|
||||
scopeId: personalScope.id!,
|
||||
userUuid: userUuid,
|
||||
role: server.CrdtScopeRole.readOnly,
|
||||
),
|
||||
);
|
||||
|
||||
final grants = await CrdtScopeMembership.memberGrants(session, userUuid);
|
||||
|
||||
expect(
|
||||
grants.singleWhere((grant) => grant.uuidScopeId == userUuid).role,
|
||||
CrdtScopeRole.readWrite,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when membership is checked for the personal scope, '
|
||||
'then the user is a member.',
|
||||
() async {
|
||||
expect(
|
||||
await CrdtScopeMembership.isMember(
|
||||
session,
|
||||
userUuid: userUuid,
|
||||
scopeUuid: userUuid,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when membership is checked for the granted shared scope, '
|
||||
'then the user is a member.',
|
||||
() async {
|
||||
expect(
|
||||
await CrdtScopeMembership.isMember(
|
||||
session,
|
||||
userUuid: userUuid,
|
||||
scopeUuid: sharedScopeUuid,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when the role is resolved for the granted shared scope, '
|
||||
'then the stored membership role is returned.',
|
||||
() async {
|
||||
expect(
|
||||
await CrdtScopeMembership.roleOf(
|
||||
session,
|
||||
userUuid: userUuid,
|
||||
scopeUuid: sharedScopeUuid,
|
||||
),
|
||||
CrdtScopeRole.readWrite,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when the role is resolved for the personal scope, '
|
||||
'then the readWrite role is returned.',
|
||||
() async {
|
||||
expect(
|
||||
await CrdtScopeMembership.roleOf(
|
||||
session,
|
||||
userUuid: userUuid,
|
||||
scopeUuid: userUuid,
|
||||
),
|
||||
CrdtScopeRole.readWrite,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when membership is checked for a scope the user does not belong to, '
|
||||
'then the user is not a member.',
|
||||
() async {
|
||||
final session = sessionBuilder.build();
|
||||
|
||||
expect(
|
||||
await CrdtScopeMembership.isMember(
|
||||
session,
|
||||
userUuid: userUuid,
|
||||
scopeUuid: otherScopeUuid,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when the role is resolved for a scope without a CRDT scope row, '
|
||||
'then no role is returned.',
|
||||
() async {
|
||||
final missingScopeUuid = const Uuid().v7obj();
|
||||
|
||||
expect(
|
||||
await CrdtScopeMembership.roleOf(
|
||||
session,
|
||||
userUuid: userUuid,
|
||||
scopeUuid: missingScopeUuid,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"when the role is resolved for the other user's scope, "
|
||||
'then the correct role is returned.',
|
||||
() async {
|
||||
expect(
|
||||
await CrdtScopeMembership.roleOf(
|
||||
session,
|
||||
userUuid: otherUserUuid,
|
||||
scopeUuid: otherScopeUuid,
|
||||
),
|
||||
CrdtScopeRole.readOnly,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when a stored membership row has no role, '
|
||||
'then resolving grants fails instead of treating it as a grant.',
|
||||
() async {
|
||||
final scope = await server.CrdtScope.db.insertRow(
|
||||
session,
|
||||
server.CrdtScope(uuidScopeId: const Uuid().v7obj()),
|
||||
);
|
||||
final encodedScopeId = ValueEncoder.instance.convert(scope.id);
|
||||
final encodedUserUuid = ValueEncoder.instance.convert(userUuid);
|
||||
|
||||
await session.db.unsafeExecute(
|
||||
'ALTER TABLE "crdt_scope_members" ALTER COLUMN "role" DROP NOT NULL',
|
||||
);
|
||||
await session.db.unsafeExecute(
|
||||
'INSERT INTO "crdt_scope_members" ("scopeId", "userUuid", "role") '
|
||||
'VALUES ($encodedScopeId, $encodedUserUuid, NULL)',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
CrdtScopeMembership.memberGrants(session, userUuid),
|
||||
throwsA(
|
||||
isA<StateError>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
'crdt_scope_members.role must not be null.',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue