Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions docs/architecture/adr/0064-freeform-whiteboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,10 @@ Designed against the platform's control baseline
co-editing op is an independent single-row write — no whole-scene read-modify-
write, so the storage-layer race below is closed. It still rides along in the
admin backup snapshot (intended).
- **−** Same-item concurrent edits are still last-write-wins (a CRDT/OT upgrade
remains open); this is convergence, not conflict-free merge.
- **−** Only edits to the *exact same field* of one node remain last-write-wins
(cross-field edits now merge). A full CRDT/OT was evaluated and declined as
disproportionate — see the addendum. This is field-level convergence, not
conflict-free character-level merge.

## Alternatives considered
- **Embed a third-party board (Miro/Mural) via iframe/SDK** — rejected: sends
Expand Down Expand Up @@ -124,9 +126,19 @@ other's whole scene. Co-editing replaces that with **granular, authorized ops**:
the whole scene to reconcile.

**Convergence, not CRDT.** Concurrent edits to *different* items are fully
independent. Concurrent edits to the *same* item are last-write-wins and
reconcile on the next reconnect/refetch. This is a large step up from whole-scene
LWW without the weight of a CRDT/OT engine.
independent. Edits to the *same* node are now **field-level**: a co-editing op
carries only the properties that changed (a move sends geometry, a recolour sends
the colour, a text edit sends the text), and the server merges per field — so two
people editing different aspects of one node (A moves it, B recolours it) both
survive. Only two edits to the *exact same field* remain last-write-wins,
reconciling on the next refetch. A full CRDT/OT engine was **evaluated and
declined**: it would mean a heavy dependency (Yjs/Automerge), replacing the typed
rows with an opaque CRDT document and a binary update protocol, and conflict-free
merge semantics for spatial data — disproportionate for a bounded brainstorming
canvas whose realistic conflict (a sub-RTT race on the *same field* of the *same*
node) is already rare and self-heals. Field-level merge is the proportionate step:
it removes the cross-field clobber with no new dependency and keeps the typed-row
model.

**Residual race — resolved.** The first cut persisted each op by rewriting the
whole `Setting` scene blob, so two writers to the *same* scene within the same
Expand Down
15 changes: 12 additions & 3 deletions docs/architecture/adr/0066-statement-of-applicability.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,18 @@ the four themes (Organizational 37, People 8, Physical 14, Technological 34).
- **−** The catalogue is ISO 27001:2022-specific; other frameworks (SOC 2, NIST
CSF) would each need their own catalogue if the org wants their SoAs too — a
clean follow-up (the coverage roll-up and row model generalise).
- **−** Coverage is a *roll-up of stated status*, not automated evidence
collection; linking a control to concrete system evidence (e.g. "A.8.13 backup"
→ the backup-run record) remains a future enhancement.
- **+** **Automated platform-evidence linkage:** ~20 Annex A controls the Atlas
platform satisfies by construction (RBAC → A.5.15/A.5.18/A.8.3; append-only
audit log → A.8.15; backups → A.8.13; OTel → A.8.16; CI SAST/SCA/DAST →
A.8.8/A.8.25/A.8.28/A.8.29; secret redaction → A.8.11; TLS/CSP →
A.8.20/A.8.24; Entra SSO → A.5.16/A.5.17/A.8.5) carry a standing evidence note
(`Soa.PlatformEvidence`) and a "platform-evidenced" coverage count, so a SoA
review starts from what the product already provides instead of a blank sheet.
- **−** Per-control evidence beyond the platform set is still owner-entered;
linking to *project-specific* artefacts (a specific backup schedule, a DPA) is a
future enhancement. SoAs for other frameworks (SOC 2, NIST CSF) would each need
their own catalogue — the row model and coverage roll-up generalise, so it is a
data-addition, not a redesign.

## Alternatives considered
- **Keep only the free-form control-evidence register** — rejected: it can't be a
Expand Down
24 changes: 24 additions & 0 deletions server/Atlas.Tests/PiBoardAndSettingsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,30 @@ public async Task Task_list_reports_canMove_for_planner_but_not_stakeholder()
Assert.False(asStk.GetProperty("canMove").GetBoolean());
}

[Fact]
public async Task Whiteboard_node_patches_merge_per_field_without_clobbering()
{
var incId = (await Json(await Send(HttpMethod.Post, "/api/v1/increments", new { name = "WB PI merge" }))).GetProperty("id").GetInt32();
var wb = $"/api/v1/whiteboards/pi/{incId}";

// Create a node.
Assert.Equal(HttpStatusCode.OK, (await Send(HttpMethod.Put, $"{wb}/node",
new { id = "m1", kind = "note", x = 10.0, y = 10.0, w = 160.0, h = 150.0, color = "#FFE8A3" })).StatusCode);

// Two independent field-level edits: one recolours, one moves.
Assert.Equal(HttpStatusCode.OK, (await Send(HttpMethod.Put, $"{wb}/node", new { id = "m1", color = "#BBDEFB" })).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await Send(HttpMethod.Put, $"{wb}/node", new { id = "m1", x = 300.0, y = 220.0 })).StatusCode);

// Both survive — the move did not revert the colour, the recolour did not
// revert the position, and the untouched size is intact.
var node = (await Json(await Send(HttpMethod.Get, wb))).GetProperty("scene").GetProperty("nodes")
.EnumerateArray().First(n => n.GetProperty("id").GetString() == "m1");
Assert.Equal("#BBDEFB", node.GetProperty("color").GetString());
Assert.Equal(300.0, node.GetProperty("x").GetDouble());
Assert.Equal(220.0, node.GetProperty("y").GetDouble());
Assert.Equal(160.0, node.GetProperty("w").GetDouble());
}

[Fact]
public async Task Whiteboard_backfill_migrates_a_legacy_setting_blob_to_rows()
{
Expand Down
17 changes: 17 additions & 0 deletions server/Atlas.Tests/SoaTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,23 @@ public async Task Soa_decisions_persist_and_roll_up_into_coverage()
Assert.Equal(2, cov.GetProperty("reviewed").GetInt32());
}

[Fact]
public async Task Soa_surfaces_platform_evidence_for_controls_atlas_implements()
{
var id = await NewProject("SoA evidence");
var soa = await Json(await Send(HttpMethod.Get, $"/api/v1/projects/{id}/soa"));

// The platform evidences a set of controls by construction (audit log, RBAC…).
Assert.True(soa.GetProperty("coverage").GetProperty("autoEvidenced").GetInt32() >= 15);

var logging = soa.GetProperty("controls").EnumerateArray().First(c => c.GetProperty("ref").GetString() == "A.8.15");
Assert.Contains("audit log", logging.GetProperty("autoEvidence").GetString(), StringComparison.OrdinalIgnoreCase);

// A control with no platform mechanism carries no auto-evidence.
var physical = soa.GetProperty("controls").EnumerateArray().First(c => c.GetProperty("ref").GetString() == "A.7.1");
Assert.Equal("", physical.GetProperty("autoEvidence").GetString());
}

[Fact]
public async Task Soa_rejects_unknown_control_and_bad_status()
{
Expand Down
4 changes: 2 additions & 2 deletions server/Dtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ public record SecurityReviewGateDto(int Id, string Name, string Type, string Rev
public record SecurityDto(bool CanEdit, SecurityProfileDto Profile, List<SecurityControlDto> Controls, List<SecurityReviewGateDto> ReviewGates);

// Statement of Applicability (ISO 27001:2022 Annex A).
public record SoaControlDto(string Ref, string Title, string Theme, bool Applicable, string Justification, string Status, string Owner);
public record SoaCoverageDto(int Total, int Applicable, int Excluded, int Implemented, int Reviewed, int ImplementedPct);
public record SoaControlDto(string Ref, string Title, string Theme, bool Applicable, string Justification, string Status, string Owner, string AutoEvidence = "");
public record SoaCoverageDto(int Total, int Applicable, int Excluded, int Implemented, int Reviewed, int AutoEvidenced, int ImplementedPct);
public record SoaDto(bool CanEdit, SoaCoverageDto Coverage, List<SoaControlDto> Controls);

public record ProjectTaskDto(int Id, string Code, string Name, string Epic, string Assignee,
Expand Down
38 changes: 35 additions & 3 deletions server/Soa.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,35 @@ void T(string theme, params (string Ref, string Title)[] rows)
static readonly Dictionary<string, string> CatalogueRefs =
Catalogue.ToDictionary(c => c.Ref, c => c.Title);

// Controls the Atlas *platform* evidences by construction — mapped to the
// concrete mechanism that satisfies them. This is the "automated control
// coverage": rather than every control starting blank, the ones the product
// itself already implements carry a standing evidence note, so a project's SoA
// review starts from what the platform provides and the owner fills the rest.
// Deliberately conservative — only defensible, platform-level mechanisms.
static readonly Dictionary<string, string> PlatformEvidence = new()
{
["A.5.15"] = "Server-authoritative RBAC capability matrix (Permissions.cs)",
["A.5.16"] = "Microsoft Entra ID identities (MSAL / OIDC)",
["A.5.17"] = "Entra SSO — no local credentials stored",
["A.5.18"] = "Capability matrix + role assignments (per-endpoint gates)",
["A.5.34"] = "GDPR DSAR export + erasure/retention; secret redaction",
["A.8.2"] = "Privileged actions gated by PlatformAdmin / cap-* capabilities",
["A.8.3"] = "Every write endpoint is capability-checked server-side",
["A.8.5"] = "Entra SSO (OIDC/PKCE) + client idle-logout",
["A.8.8"] = "CI SCA/secrets/IaC scan (Trivy) + dependency audit gate",
["A.8.11"] = "Secret redaction on GET /settings (Backups.IsSecretSetting)",
["A.8.12"] = "Secret redaction + upload allow-list + bounded inputs",
["A.8.13"] = "Admin backup snapshots (all tables) + restore",
["A.8.15"] = "Append-only audit log (AuditEvents) on every significant action",
["A.8.16"] = "OpenTelemetry traces/metrics/logs + Prometheus alert rules",
["A.8.20"] = "nginx edge + CSP/security headers + rate limiting",
["A.8.24"] = "TLS at the nginx edge; DB-hop TLS available (PGSSL)",
["A.8.25"] = "CI gates: lint · test · build + SAST/SCA/DAST",
["A.8.28"] = "SAST (Semgrep OWASP Top 10) + review discipline in CI",
["A.8.29"] = "CI test suite (API + web) + axe a11y + on-demand ZAP DAST",
};

public static void MapSoaEndpoints(this RouteGroupBuilder api)
{
// Full Statement of Applicability for a project: every Annex A control,
Expand All @@ -164,15 +193,17 @@ public static void MapSoaEndpoints(this RouteGroupBuilder api)
e?.Applicable ?? true,
e?.Justification ?? "",
e?.Status ?? "Not started",
e?.Owner ?? "");
e?.Owner ?? "",
PlatformEvidence.GetValueOrDefault(c.Ref, ""));
}).ToList();

var applicable = rows.Count(r => r.Applicable);
var implemented = rows.Count(r => r.Applicable && r.Status == "Implemented");
var reviewed = rows.Count(r => entries.ContainsKey(r.Ref));
var autoEvidenced = rows.Count(r => r.Applicable && r.AutoEvidence != "");
var coverage = new SoaCoverageDto(
Catalogue.Length, applicable, Catalogue.Length - applicable,
implemented, reviewed,
implemented, reviewed, autoEvidenced,
applicable == 0 ? 100 : (int)Math.Round(100.0 * implemented / applicable));

return Results.Ok(new SoaDto(canEdit, coverage, rows));
Expand Down Expand Up @@ -203,7 +234,8 @@ public static void MapSoaEndpoints(this RouteGroupBuilder api)
$"{id} · {CatalogueRefs[ctlRef]} → {e.Status}"));
await db.SaveChangesAsync();
return Results.Ok(new SoaControlDto(e.Ref, CatalogueRefs[e.Ref],
Catalogue.First(c => c.Ref == e.Ref).Theme, e.Applicable, e.Justification, e.Status, e.Owner));
Catalogue.First(c => c.Ref == e.Ref).Theme, e.Applicable, e.Justification, e.Status, e.Owner,
PlatformEvidence.GetValueOrDefault(e.Ref, "")));
});
}
}
52 changes: 37 additions & 15 deletions server/Whiteboards.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ namespace Atlas.Api;
// Points is only used by freehand "draw" nodes: a flat [x0,y0,x1,y1,…] polyline
// in absolute canvas coordinates.
public record WbNode(string Id, string Kind, double X, double Y, double W, double H, string? Text, string? Color, string? Icon, double[]? Points = null);
// A partial node update — every field optional except the id, so a co-editing op
// carries only what changed (field-level merge; see the node endpoint).
public record WbNodePatch(string Id, string? Kind, double? X, double? Y, double? W, double? H, string? Text, string? Color, string? Icon, double[]? Points);
public record WbEdge(string Id, string From, string To, string? Color);
public record WbScene(List<WbNode>? Nodes, List<WbEdge>? Edges);
public record SaveWhiteboardReq(WbScene? Scene);
Expand Down Expand Up @@ -238,35 +241,54 @@ public static void MapWhiteboardEndpoints(this RouteGroupBuilder api)
// Because the op originates from an authorized REST call, peers can trust
// and render it without re-checking permissions (the hub never lets a
// client send an op). Concurrent edits to *different* items are
// independent; same-item edits are last-write-wins and reconcile on the
// next reconnect/refetch.

// Upsert a single node (add or replace by id).
api.MapPut("/whiteboards/{kind}/{id}/node", async (string kind, string id, WbNode node, AtlasDbContext db, IConfiguration cfg, HttpContext http, IHubContext<BoardHub> hub) =>
// independent. For the *same* item, writes are FIELD-LEVEL: a patch only
// carries the properties that changed, so two people editing different
// aspects of one node (A moves it, B recolours it) both survive — no
// last-write-wins clobber across fields. Two edits to the *same* field are
// still last-write-wins and reconcile on the next refetch. (This is the
// proportionate step below a CRDT/OT engine, which would be disproportionate
// for a bounded brainstorming canvas — see ADR-0064.)

// Upsert a single node. The body is a partial patch (all fields optional
// except the id): a brand-new node must carry kind + geometry; an existing
// node merges only the provided, sanitised fields (single-row write).
api.MapPut("/whiteboards/{kind}/{id}/node", async (string kind, string id, WbNodePatch patch, AtlasDbContext db, IConfiguration cfg, HttpContext http, IHubContext<BoardHub> hub) =>
{
var (scope, denied) = await AuthScope(kind, id, http, db, cfg);
if (denied is not null) return denied;
var clean = SanitizeNode(node);
if (clean is null) return Results.BadRequest(new { error = "Invalid node." });
if (patch.Id is null || !IdRe.IsMatch(patch.Id)) return Results.BadRequest(new { error = "Invalid node id." });

// Upsert just this node's row — no whole-scene read-modify-write.
var existing = await db.WhiteboardNodes.FirstOrDefaultAsync(n => n.Scope == scope && n.NodeId == clean.Id);
var existing = await db.WhiteboardNodes.FirstOrDefaultAsync(n => n.Scope == scope && n.NodeId == patch.Id);
WbNode result;
if (existing is null)
{
// Create — needs a kind and full geometry (a bare patch can't seed a node).
if (patch.Kind is null || patch.X is null || patch.Y is null || patch.W is null || patch.H is null)
return Results.BadRequest(new { error = "A new node needs a kind and geometry." });
var clean = SanitizeNode(new WbNode(patch.Id, patch.Kind, patch.X.Value, patch.Y.Value, patch.W.Value, patch.H.Value, patch.Text, patch.Color, patch.Icon, patch.Points));
if (clean is null) return Results.BadRequest(new { error = "Invalid node." });
if (await db.WhiteboardNodes.CountAsync(n => n.Scope == scope) >= MaxNodes)
return Results.BadRequest(new { error = "This whiteboard is full." });
db.WhiteboardNodes.Add(ToRow(scope!, clean));
result = clean;
}
else
{
var row = ToRow(scope!, clean);
existing.Kind = row.Kind; existing.X = row.X; existing.Y = row.Y; existing.W = row.W; existing.H = row.H;
existing.Text = row.Text; existing.Color = row.Color; existing.Icon = row.Icon; existing.PointsJson = row.PointsJson;
// Merge only the provided fields (kind is immutable once created).
if (patch.X is { } x) existing.X = Clamp(x, -MaxCoord, MaxCoord);
if (patch.Y is { } y) existing.Y = Clamp(y, -MaxCoord, MaxCoord);
if (patch.W is { } w) existing.W = Clamp(w, MinSize, MaxSize);
if (patch.H is { } h) existing.H = Clamp(h, MinSize, MaxSize);
if (patch.Text is not null) existing.Text = Trim(patch.Text, MaxText);
if (patch.Color is not null) existing.Color = Color(patch.Color);
if (patch.Icon is not null) existing.Icon = Icon(patch.Icon);
if (patch.Points is not null) { var p = Points(patch.Points); existing.PointsJson = p is null ? null : JsonSerializer.Serialize(p); }
result = ToWbNode(existing);
}
db.AuditEvents.Add(Permissions.Audit(http, cfg, "Whiteboard", "Edited whiteboard node", $"{scope} · {clean.Id}"));
db.AuditEvents.Add(Permissions.Audit(http, cfg, "Whiteboard", "Edited whiteboard node", $"{scope} · {patch.Id}"));
await db.SaveChangesAsync();
await BoardHub.NotifyRoomOpAsync(hub, Room(scope!), new { t = "node", node = clean });
return Results.Ok(clean);
await BoardHub.NotifyRoomOpAsync(hub, Room(scope!), new { t = "node", node = result });
return Results.Ok(result);
});

// Delete a node (and any connectors touching it).
Expand Down
Loading
Loading