P4.3: token security at rest + dist token CLI
Subplan 02 Slice 5: Token domain entity (scopes, app/channel scoping, expiry, revocation, last-used) with boundary validation; secrets are dist_<64 hex> drawn from arc4random_buf and only their SHA-256 is persisted. check_token gates revocation > expiry > scope > app/channel; mark_token_used stamps usage for the P4.4 server auth. CLI: dist token create (raw secret shown exactly once; works on a fresh store so CI tokens can predate the first publish), list (lifecycle status, never the secret), revoke (unknown id and double-revoke are distinct errors). Every mutation appends an audit event; tokens joins db.json's persisted arrays, with an absent member loading as empty so older db.json files stay readable. make test 16/16 (new: token_check.sx unit suite, token_ops.sx pinned CLI acceptance).
This commit is contained in:
@@ -7,7 +7,8 @@
|
||||
// thing that fixes db.json's layout is the ORDER these functions `put`.
|
||||
// Every entity is emitted in its struct's declaration-field order, and the
|
||||
// top-level object is emitted as apps, releases, artifacts, channels,
|
||||
// audit_events. Re-saving an unchanged model yields byte-identical output.
|
||||
// tokens, audit_events. Re-saving an unchanged model yields byte-identical
|
||||
// output.
|
||||
//
|
||||
// Enums serialize as their lowercase variant NAME (e.g. "android_apk",
|
||||
// "percentage"), never an ordinal — readable and reorder-proof.
|
||||
@@ -31,6 +32,7 @@ jsonp :: #import "modules/std/json.sx";
|
||||
#import "../domain/release.sx";
|
||||
#import "../domain/artifact.sx";
|
||||
#import "../domain/channel.sx";
|
||||
#import "../domain/token.sx";
|
||||
#import "../domain/audit.sx";
|
||||
#import "../domain/validate.sx";
|
||||
#import "repo.sx";
|
||||
@@ -156,6 +158,21 @@ channel_to_json :: (c: Channel, alloc: Allocator) -> Value {
|
||||
return .object(o);
|
||||
}
|
||||
|
||||
token_to_json :: (t: Token, alloc: Allocator) -> Value {
|
||||
o : Object = .{};
|
||||
o.put("id", .str(t.id), alloc);
|
||||
o.put("name", .str(t.name), alloc);
|
||||
o.put("token_hash", .str(t.token_hash), alloc);
|
||||
o.put("scopes", .str(t.scopes), alloc);
|
||||
o.put("app_slug", .str(t.app_slug), alloc);
|
||||
o.put("channel", .str(t.channel), alloc);
|
||||
o.put("created_at", .int_(t.created_at), alloc);
|
||||
o.put("expires_at", .int_(t.expires_at), alloc);
|
||||
o.put("last_used_at", .int_(t.last_used_at), alloc);
|
||||
o.put("revoked_at", .int_(t.revoked_at), alloc);
|
||||
return .object(o);
|
||||
}
|
||||
|
||||
audit_to_json :: (e: AuditEvent, alloc: Allocator) -> Value {
|
||||
o : Object = .{};
|
||||
o.put("id", .str(e.id), alloc);
|
||||
@@ -193,6 +210,11 @@ model_to_json :: (self: *Repo, alloc: Allocator) -> Value {
|
||||
while i < self.channels.len { chans.add(channel_to_json(self.channels.items[i], alloc), alloc); i += 1; }
|
||||
root.put("channels", .array(chans), alloc);
|
||||
|
||||
toks : Array = .{};
|
||||
i = 0;
|
||||
while i < self.tokens.len { toks.add(token_to_json(self.tokens.items[i], alloc), alloc); i += 1; }
|
||||
root.put("tokens", .array(toks), alloc);
|
||||
|
||||
evs : Array = .{};
|
||||
i = 0;
|
||||
while i < self.audit_events.len { evs.add(audit_to_json(self.audit_events.items[i], alloc), alloc); i += 1; }
|
||||
@@ -354,6 +376,21 @@ channel_from_json :: (o: Object, alloc: Allocator) -> (Channel, !LoadErr) {
|
||||
return c;
|
||||
}
|
||||
|
||||
token_from_json :: (o: Object, alloc: Allocator) -> (Token, !LoadErr) {
|
||||
t : Token = .{};
|
||||
t.id = try req_str(o, "id", alloc);
|
||||
t.name = try req_str(o, "name", alloc);
|
||||
t.token_hash = try req_str(o, "token_hash", alloc);
|
||||
t.scopes = try req_str(o, "scopes", alloc);
|
||||
t.app_slug = try req_str(o, "app_slug", alloc);
|
||||
t.channel = try req_str(o, "channel", alloc);
|
||||
t.created_at = try req_int(o, "created_at");
|
||||
t.expires_at = try req_int(o, "expires_at");
|
||||
t.last_used_at = try req_int(o, "last_used_at");
|
||||
t.revoked_at = try req_int(o, "revoked_at");
|
||||
return t;
|
||||
}
|
||||
|
||||
audit_from_json :: (o: Object, alloc: Allocator) -> (AuditEvent, !LoadErr) {
|
||||
e : AuditEvent = .{};
|
||||
e.id = try req_str(o, "id", alloc);
|
||||
@@ -407,6 +444,22 @@ load_into :: (repo: *Repo, bytes: string, scratch: Allocator) -> !LoadErr {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// OPTIONAL member: an absent `tokens` array loads as zero tokens, so
|
||||
// db.json files from layouts without tokens stay readable. A PRESENT
|
||||
// member is held to the same strictness as everything else.
|
||||
tokq := db_obj_find(ro, "tokens");
|
||||
if tokq != null {
|
||||
tokv := tokq!;
|
||||
if tokv != .array { raise error.BadShape; }
|
||||
tok_arr := tokv.array;
|
||||
i = 0;
|
||||
while i < tok_arr.len {
|
||||
o := try db_req_obj(tok_arr.items[i]);
|
||||
repo.create_token(try token_from_json(o, oa));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
ev_arr := try db_req_arr(ro, "audit_events");
|
||||
i = 0;
|
||||
while i < ev_arr.len {
|
||||
|
||||
Reference in New Issue
Block a user