feat(online-inbox): carry ownerId on sync to prepare for multi-user
Plumb a per-resource owner (Zitadel sub) through the sync contract without enforcing isolation client-side — the server stays the authority. - Dtos: add optional ownerId to RemoteList/RemoteTask/MirrorTask - JwtClaims: decode the sub claim from the access token (never throws) - OnlineSyncService: stamp ownerId on pushed lists + mirror; defensively skip pulled tasks owned by a different user (unowned tasks still sync, so single-user behavior is unchanged) - docs: contract documents ownerId + multi-user readiness Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,17 @@ Sync directions (each one-way per entity → no conflict resolution needed):
|
|||||||
- **Idle tasks**: desktop mirrors its Idle backlog up; the web can create new ones, which the
|
- **Idle tasks**: desktop mirrors its Idle backlog up; the web can create new ones, which the
|
||||||
desktop pulls down and then owns.
|
desktop pulls down and then owns.
|
||||||
|
|
||||||
Single user. Both the desktop and the web client authenticate as the **same Zitadel user**.
|
Single user today. Both the desktop and the web client authenticate as the **same Zitadel
|
||||||
|
user**.
|
||||||
|
|
||||||
|
**Multi-user readiness (`ownerId`).** Each resource is owned by a Zitadel subject (`sub`).
|
||||||
|
`RemoteList`, `RemoteTask`, and `MirrorTask` carry an optional `ownerId` field. The desktop
|
||||||
|
stamps its own `sub` (decoded from the access token) onto everything it pushes, and
|
||||||
|
defensively ignores any pulled task whose `ownerId` is set to a *different* user; an absent
|
||||||
|
`ownerId` is treated as unowned/legacy and still syncs. This keeps the contract ready for
|
||||||
|
multiple users **without enforcing isolation client-side** — the server remains the
|
||||||
|
authority that scopes every request by the token's `sub`. When the server goes multi-user it
|
||||||
|
should partition all rows by owner and ignore (or validate) the client-supplied `ownerId`.
|
||||||
|
|
||||||
**Access control (as of 2026-06-10).** Access is granted by assigning the **"user" project
|
**Access control (as of 2026-06-10).** Access is granted by assigning the **"user" project
|
||||||
role** in the Zitadel project "ClaudeDo" (id `376787351902355727`, issuer
|
role** in the Zitadel project "ClaudeDo" (id `376787351902355727`, issuer
|
||||||
@@ -94,13 +104,17 @@ surfaces "missing 'user' role in Zitadel" and pauses sync until the user signs i
|
|||||||
|
|
||||||
| Method & path | Caller | Body | Response |
|
| Method & path | Caller | Body | Response |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `PUT /lists` | desktop | `[{ "id", "name" }]` — the FULL catalog | `200` |
|
| `PUT /lists` | desktop | `[{ "id", "name", "ownerId"? }]` — the FULL catalog | `200` |
|
||||||
| `GET /lists` | web | — | `200 [{ "id", "name" }]` |
|
| `GET /lists` | web | — | `200 [{ "id", "name", "ownerId"? }]` |
|
||||||
| `GET /lists/{id}/tasks` | web | — | `200` tasks in that list (`404` if list unknown) |
|
| `GET /lists/{id}/tasks` | web | — | `200` tasks in that list (`404` if list unknown) |
|
||||||
| `POST /tasks` | web | `{ "title", "description"?, "listId" }` | `201` created task incl. `id` |
|
| `POST /tasks` | web | `{ "title", "description"?, "listId" }` | `201` created task incl. `id` |
|
||||||
| `GET /tasks?imported=false` | desktop | — | `200 [{ "id","listId","title","description","createdAt" }]` |
|
| `GET /tasks?imported=false` | desktop | — | `200 [{ "id","listId","title","description","createdAt","ownerId"? }]` |
|
||||||
| `POST /tasks/{id}/imported` | desktop | — | `200` (`404` if unknown) |
|
| `POST /tasks/{id}/imported` | desktop | — | `200` (`404` if unknown) |
|
||||||
| `PUT /tasks/mirror` | desktop | `[{ "id","listId","title","description" }]` — full Idle set | `200` |
|
| `PUT /tasks/mirror` | desktop | `[{ "id","listId","title","description","ownerId"? }]` — full Idle set | `200` |
|
||||||
|
|
||||||
|
`ownerId` (optional, see §1) is the Zitadel `sub` of the owner. The desktop sends it on push
|
||||||
|
and ignores pulled tasks owned by a different user; the server should derive/validate it from
|
||||||
|
the token rather than trust the client value.
|
||||||
|
|
||||||
Semantics:
|
Semantics:
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,26 @@ using System.Text.Json.Serialization;
|
|||||||
|
|
||||||
namespace ClaudeDo.Worker.Online;
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
// OwnerId carries the resource owner's Zitadel subject (sub). It is nullable and optional so
|
||||||
|
// the contract stays multi-user-ready without changing single-user behavior: today the desktop
|
||||||
|
// stamps it on push and defensively ignores pulled tasks owned by a different user, while the
|
||||||
|
// server remains the authority that scopes data by the token's sub.
|
||||||
public sealed record RemoteList(
|
public sealed record RemoteList(
|
||||||
[property: JsonPropertyName("id")] string Id,
|
[property: JsonPropertyName("id")] string Id,
|
||||||
[property: JsonPropertyName("name")] string Name);
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("ownerId")] string? OwnerId = null);
|
||||||
|
|
||||||
public sealed record RemoteTask(
|
public sealed record RemoteTask(
|
||||||
[property: JsonPropertyName("id")] string Id,
|
[property: JsonPropertyName("id")] string Id,
|
||||||
[property: JsonPropertyName("listId")] string ListId,
|
[property: JsonPropertyName("listId")] string ListId,
|
||||||
[property: JsonPropertyName("title")] string Title,
|
[property: JsonPropertyName("title")] string Title,
|
||||||
[property: JsonPropertyName("description")] string? Description,
|
[property: JsonPropertyName("description")] string? Description,
|
||||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||||
|
[property: JsonPropertyName("ownerId")] string? OwnerId = null);
|
||||||
|
|
||||||
public sealed record MirrorTask(
|
public sealed record MirrorTask(
|
||||||
[property: JsonPropertyName("id")] string Id,
|
[property: JsonPropertyName("id")] string Id,
|
||||||
[property: JsonPropertyName("listId")] string ListId,
|
[property: JsonPropertyName("listId")] string ListId,
|
||||||
[property: JsonPropertyName("title")] string Title,
|
[property: JsonPropertyName("title")] string Title,
|
||||||
[property: JsonPropertyName("description")] string? Description);
|
[property: JsonPropertyName("description")] string? Description,
|
||||||
|
[property: JsonPropertyName("ownerId")] string? OwnerId = null);
|
||||||
|
|||||||
49
src/ClaudeDo.Worker/Online/JwtClaims.cs
Normal file
49
src/ClaudeDo.Worker/Online/JwtClaims.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal, dependency-free reader for a JWT access token's payload claims. Used to resolve the
|
||||||
|
/// current user's Zitadel subject (<c>sub</c>) so sync payloads can be stamped with an owner.
|
||||||
|
/// Never throws — returns null when the token is absent or cannot be parsed.
|
||||||
|
/// </summary>
|
||||||
|
public static class JwtClaims
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the <c>sub</c> claim of the JWT, or null if the token is absent/unparseable or
|
||||||
|
/// carries no subject.
|
||||||
|
/// </summary>
|
||||||
|
public static string? GetSubject(string? jwt)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(jwt))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var parts = jwt.Split('.');
|
||||||
|
if (parts.Length < 2)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(Base64UrlDecode(parts[1]));
|
||||||
|
if (doc.RootElement.TryGetProperty("sub", out var sub) &&
|
||||||
|
sub.ValueKind == JsonValueKind.String)
|
||||||
|
return sub.GetString();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Base64UrlDecode(string input)
|
||||||
|
{
|
||||||
|
var s = input.Replace('-', '+').Replace('_', '/');
|
||||||
|
switch (s.Length % 4)
|
||||||
|
{
|
||||||
|
case 2: s += "=="; break;
|
||||||
|
case 3: s += "="; break;
|
||||||
|
}
|
||||||
|
return Convert.FromBase64String(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,10 @@ public sealed class OnlineSyncService : BackgroundService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the current user's Zitadel subject so sync payloads carry an owner and pulls
|
||||||
|
// can be guarded. Null today (single user / server derives it from the token).
|
||||||
|
var ownerId = JwtClaims.GetSubject(token);
|
||||||
|
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
var tasks = new TaskRepository(ctx);
|
var tasks = new TaskRepository(ctx);
|
||||||
var lists = new ListRepository(ctx);
|
var lists = new ListRepository(ctx);
|
||||||
@@ -81,6 +85,15 @@ public sealed class OnlineSyncService : BackgroundService
|
|||||||
var unimported = await _api.GetUnimportedTasksAsync(ct);
|
var unimported = await _api.GetUnimportedTasksAsync(ct);
|
||||||
foreach (var remote in unimported)
|
foreach (var remote in unimported)
|
||||||
{
|
{
|
||||||
|
// Multi-user guard: never import a task explicitly owned by a different user.
|
||||||
|
// Unowned tasks (ownerId == null) stay importable so single-user behavior is intact.
|
||||||
|
if (ownerId is not null && remote.OwnerId is not null && remote.OwnerId != ownerId)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"OnlineSyncService: remote task {Id} is owned by another user; skipping", remote.Id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var existing = await tasks.GetByIdAsync(remote.Id, ct);
|
var existing = await tasks.GetByIdAsync(remote.Id, ct);
|
||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
{
|
{
|
||||||
@@ -115,13 +128,15 @@ public sealed class OnlineSyncService : BackgroundService
|
|||||||
_logger.LogInformation("OnlineSyncService: imported task {Id} ('{Title}')", remote.Id, remote.Title);
|
_logger.LogInformation("OnlineSyncService: imported task {Id} ('{Title}')", remote.Id, remote.Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: push full list catalog.
|
// Step 2: push full list catalog, stamped with the owner.
|
||||||
var allLists = await lists.GetAllAsync(ct);
|
var allLists = await lists.GetAllAsync(ct);
|
||||||
var remoteLists = allLists.Select(l => new RemoteList(l.Id, l.Name)).ToList();
|
var remoteLists = allLists.Select(l => new RemoteList(l.Id, l.Name, ownerId)).ToList();
|
||||||
await _api.PutListsAsync(remoteLists, ct);
|
await _api.PutListsAsync(remoteLists, ct);
|
||||||
|
|
||||||
// Step 3: push current Idle backlog mirror.
|
// Step 3: push current Idle backlog mirror, stamped with the owner.
|
||||||
var mirror = await OnlineBacklog.CurrentAsync(tasks, ct);
|
var mirror = (await OnlineBacklog.CurrentAsync(tasks, ct))
|
||||||
|
.Select(m => m with { OwnerId = ownerId })
|
||||||
|
.ToList();
|
||||||
await _api.PutMirrorAsync(mirror, ct);
|
await _api.PutMirrorAsync(mirror, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
tests/ClaudeDo.Worker.Tests/Online/JwtClaimsTests.cs
Normal file
40
tests/ClaudeDo.Worker.Tests/Online/JwtClaimsTests.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Text;
|
||||||
|
using ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Online;
|
||||||
|
|
||||||
|
public sealed class JwtClaimsTests
|
||||||
|
{
|
||||||
|
// Builds a fake JWT (header.payload.signature) carrying the given JSON payload.
|
||||||
|
internal static string MakeToken(string payloadJson)
|
||||||
|
{
|
||||||
|
static string B64Url(string s) =>
|
||||||
|
Convert.ToBase64String(Encoding.UTF8.GetBytes(s))
|
||||||
|
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
return $"{B64Url("{\"alg\":\"RS256\"}")}.{B64Url(payloadJson)}.sig";
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSubject_ReturnsSub()
|
||||||
|
{
|
||||||
|
var token = MakeToken("{\"sub\":\"user-123\",\"email\":\"a@b.c\"}");
|
||||||
|
Assert.Equal("user-123", JwtClaims.GetSubject(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSubject_Null_WhenNoSub()
|
||||||
|
{
|
||||||
|
var token = MakeToken("{\"email\":\"a@b.c\"}");
|
||||||
|
Assert.Null(JwtClaims.GetSubject(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("not-a-jwt")]
|
||||||
|
[InlineData("opaque.token")]
|
||||||
|
public void GetSubject_Null_ForUnparseableInput(string? token)
|
||||||
|
{
|
||||||
|
Assert.Null(JwtClaims.GetSubject(token));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -209,6 +209,76 @@ public sealed class OnlineSyncServiceTests : IDisposable
|
|||||||
Assert.Equal(0, api.CallCount);
|
Assert.Equal(0, api.CallCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- multi-user: owner stamping + guard ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Tick_StampsOwnerId_OnPushedListsAndMirror()
|
||||||
|
{
|
||||||
|
var (listId, ctx, tasks, _) = await SeedAsync();
|
||||||
|
using var _ = ctx;
|
||||||
|
await tasks.AddAsync(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Idle",
|
||||||
|
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
var token = JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}");
|
||||||
|
var api = new FakeApi();
|
||||||
|
var svc = BuildService(api, token);
|
||||||
|
|
||||||
|
await svc.TickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.All(api.ReceivedLists, l => Assert.Equal("owner-1", l.OwnerId));
|
||||||
|
Assert.NotEmpty(api.ReceivedMirror);
|
||||||
|
Assert.All(api.ReceivedMirror, m => Assert.Equal("owner-1", m.OwnerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Tick_SkipsRemoteTask_OwnedByAnotherUser()
|
||||||
|
{
|
||||||
|
var (listId, ctx, tasks, _) = await SeedAsync();
|
||||||
|
using var _ = ctx;
|
||||||
|
|
||||||
|
var foreignId = Guid.NewGuid().ToString();
|
||||||
|
var api = new FakeApi
|
||||||
|
{
|
||||||
|
UnimportedTasks =
|
||||||
|
[
|
||||||
|
new RemoteTask(foreignId, listId, "Theirs", null, DateTimeOffset.UtcNow, OwnerId: "owner-2"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var svc = BuildService(api, JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}"));
|
||||||
|
|
||||||
|
await svc.TickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(await tasks.GetByIdAsync(foreignId));
|
||||||
|
Assert.DoesNotContain(foreignId, api.MarkedImported);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Tick_Imports_OwnTask_And_UnownedTask()
|
||||||
|
{
|
||||||
|
var (listId, ctx, tasks, _) = await SeedAsync();
|
||||||
|
using var _ = ctx;
|
||||||
|
|
||||||
|
var mine = Guid.NewGuid().ToString();
|
||||||
|
var unowned = Guid.NewGuid().ToString();
|
||||||
|
var api = new FakeApi
|
||||||
|
{
|
||||||
|
UnimportedTasks =
|
||||||
|
[
|
||||||
|
new RemoteTask(mine, listId, "Mine", null, DateTimeOffset.UtcNow, OwnerId: "owner-1"),
|
||||||
|
new RemoteTask(unowned, listId, "Unowned", null, DateTimeOffset.UtcNow),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var svc = BuildService(api, JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}"));
|
||||||
|
|
||||||
|
await svc.TickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(await tasks.GetByIdAsync(mine));
|
||||||
|
Assert.NotNull(await tasks.GetByIdAsync(unowned));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- already-imported task on server ----
|
// ---- already-imported task on server ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user