diff --git a/docs/online-inbox-api-contract.md b/docs/online-inbox-api-contract.md index 377ee06..48c34ff 100644 --- a/docs/online-inbox-api-contract.md +++ b/docs/online-inbox-api-contract.md @@ -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 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 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 | |---|---|---|---| -| `PUT /lists` | desktop | `[{ "id", "name" }]` — the FULL catalog | `200` | -| `GET /lists` | web | — | `200 [{ "id", "name" }]` | +| `PUT /lists` | desktop | `[{ "id", "name", "ownerId"? }]` — the FULL catalog | `200` | +| `GET /lists` | web | — | `200 [{ "id", "name", "ownerId"? }]` | | `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` | -| `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) | -| `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: diff --git a/src/ClaudeDo.Worker/Online/Dtos.cs b/src/ClaudeDo.Worker/Online/Dtos.cs index 8eccb61..77e1627 100644 --- a/src/ClaudeDo.Worker/Online/Dtos.cs +++ b/src/ClaudeDo.Worker/Online/Dtos.cs @@ -2,19 +2,26 @@ using System.Text.Json.Serialization; 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( [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( [property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("listId")] string ListId, [property: JsonPropertyName("title")] string Title, [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( [property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("listId")] string ListId, [property: JsonPropertyName("title")] string Title, - [property: JsonPropertyName("description")] string? Description); + [property: JsonPropertyName("description")] string? Description, + [property: JsonPropertyName("ownerId")] string? OwnerId = null); diff --git a/src/ClaudeDo.Worker/Online/JwtClaims.cs b/src/ClaudeDo.Worker/Online/JwtClaims.cs new file mode 100644 index 0000000..ab9f8ef --- /dev/null +++ b/src/ClaudeDo.Worker/Online/JwtClaims.cs @@ -0,0 +1,49 @@ +using System.Text.Json; + +namespace ClaudeDo.Worker.Online; + +/// +/// Minimal, dependency-free reader for a JWT access token's payload claims. Used to resolve the +/// current user's Zitadel subject (sub) so sync payloads can be stamped with an owner. +/// Never throws — returns null when the token is absent or cannot be parsed. +/// +public static class JwtClaims +{ + /// + /// Returns the sub claim of the JWT, or null if the token is absent/unparseable or + /// carries no subject. + /// + 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); + } +} diff --git a/src/ClaudeDo.Worker/Online/OnlineSyncService.cs b/src/ClaudeDo.Worker/Online/OnlineSyncService.cs index 383b0c0..091765a 100644 --- a/src/ClaudeDo.Worker/Online/OnlineSyncService.cs +++ b/src/ClaudeDo.Worker/Online/OnlineSyncService.cs @@ -73,6 +73,10 @@ public sealed class OnlineSyncService : BackgroundService 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); var tasks = new TaskRepository(ctx); var lists = new ListRepository(ctx); @@ -81,6 +85,15 @@ public sealed class OnlineSyncService : BackgroundService var unimported = await _api.GetUnimportedTasksAsync(ct); 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); 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); } - // Step 2: push full list catalog. + // Step 2: push full list catalog, stamped with the owner. 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); - // Step 3: push current Idle backlog mirror. - var mirror = await OnlineBacklog.CurrentAsync(tasks, ct); + // Step 3: push current Idle backlog mirror, stamped with the owner. + var mirror = (await OnlineBacklog.CurrentAsync(tasks, ct)) + .Select(m => m with { OwnerId = ownerId }) + .ToList(); await _api.PutMirrorAsync(mirror, ct); } } diff --git a/tests/ClaudeDo.Worker.Tests/Online/JwtClaimsTests.cs b/tests/ClaudeDo.Worker.Tests/Online/JwtClaimsTests.cs new file mode 100644 index 0000000..d769b76 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Online/JwtClaimsTests.cs @@ -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)); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs index a5c2535..15288d6 100644 --- a/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs @@ -209,6 +209,76 @@ public sealed class OnlineSyncServiceTests : IDisposable 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 ---- [Fact]