feat(coordinator): worker trust tiers (C1) — enroll volunteers, quarantine untrusted

- migration 0012: workers.owner_id + trust_level (trusted/untrusted)
- verifier/authctx read the JWT verified claim; IsTrusted() = admin||verified
- /workers/register resolves trust from auth: service token or verified/admin
  JWT -> trusted; plain user JWT -> untrusted, tagged with owner_id
- claim quarantines untrusted workers (no tasks) until quorum (C2) lands
- unit tests for trust resolution, quarantine, and the verified claim

Additive and backward compatible: shared-token workers stay trusted, so the
existing worker flow and team tests are unchanged. Quorum verification (C2)
is deferred.
This commit is contained in:
Efremenko Arhip
2026-07-26 19:18:50 +03:00
parent c6a66747eb
commit 80ff72a0fe
13 changed files with 195 additions and 18 deletions
+6 -4
View File
@@ -13,8 +13,9 @@ import (
// Claims is the subset of a userservice token the coordinator cares about.
type Claims struct {
UserID uuid.UUID
Role string
UserID uuid.UUID
Role string
Verified bool
}
// Verifier checks tokens against the shared HS256 secret.
@@ -32,7 +33,8 @@ func NewVerifier(secret string) *Verifier {
}
type claims struct {
Role string `json:"role"`
Role string `json:"role"`
Verified bool `json:"verified"`
jwt.RegisteredClaims
}
@@ -54,5 +56,5 @@ func (v *Verifier) Verify(raw string) (Claims, error) {
if err != nil {
return Claims{}, fmt.Errorf("token subject is not a uuid: %w", err)
}
return Claims{UserID: id, Role: c.Role}, nil
return Claims{UserID: id, Role: c.Role, Verified: c.Verified}, nil
}
+20 -1
View File
@@ -11,9 +11,15 @@ import (
const secret = "coordinator-verify-secret-32-bytes!!"
func sign(t *testing.T, method jwt.SigningMethod, key any, sub, role string, exp time.Time) string {
t.Helper()
return signVerified(t, method, key, sub, role, false, exp)
}
func signVerified(t *testing.T, method jwt.SigningMethod, key any, sub, role string, verified bool, exp time.Time) string {
t.Helper()
tok := jwt.NewWithClaims(method, claims{
Role: role,
Role: role,
Verified: verified,
RegisteredClaims: jwt.RegisteredClaims{
Subject: sub,
ExpiresAt: jwt.NewNumericDate(exp),
@@ -26,6 +32,19 @@ func sign(t *testing.T, method jwt.SigningMethod, key any, sub, role string, exp
return raw
}
func TestVerifyCarriesVerifiedClaim(t *testing.T) {
v := NewVerifier(secret)
raw := signVerified(t, jwt.SigningMethodHS256, []byte(secret), uuid.New().String(), "user", true, time.Now().Add(time.Hour))
claims, err := v.Verify(raw)
if err != nil {
t.Fatalf("verify: %v", err)
}
if !claims.Verified {
t.Error("verified claim not read from token")
}
}
func TestNewVerifierNilWhenNoSecret(t *testing.T) {
if NewVerifier("") != nil {
t.Error("empty secret must yield a nil verifier (auth disabled)")