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:
@@ -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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user