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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,4 @@ Every production feature requires tests and every architecture change requires a

**Permanent — no ADR lifts these.** Never hard-code secrets. Never change a contract without consumer analysis. Never put detailed metrics on-chain. Never put tenant payloads, logs, secrets, or any personal data on-chain: consensus state cannot be erased, so only hashes and commitments may cross that line (ADR-012 §3).

**Prohibited until the ADR gate named in ADR-012 §6 is accepted.** Do not introduce another database (ADR-016, ADR-021), direct Agent-to-chain access (ADR-020), runtime orchestration (ADR-019), decentralized storage (ADR-021), a TEE trust root (ADR-022), or a replacement for `EnsureRoot` governance (ADR-023). Kubernetes remains prohibited under ADR-006, which fixes Docker as the runtime; adopting it needs its own accepted ADR.
**Prohibited until the ADR gate named in ADR-012 §6 is accepted.** Do not introduce another database (ADR-024, ADR-021), direct Agent-to-chain access (ADR-020), runtime orchestration (ADR-019), decentralized storage (ADR-021), a TEE trust root (ADR-022), or a replacement for `EnsureRoot` governance (ADR-023). Kubernetes remains prohibited under ADR-006, which fixes Docker as the runtime; adopting it needs its own accepted ADR.
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Progressively remove centralized frontend, database, scheduler, and operational
| Issue | Gate |
|---|---|
| #32 — decentralization roadmap and trust boundaries | ADR-012 (accepted) |
| #33 — replicated off-chain data plane | ADR-016 |
| #33 — replicated off-chain data plane | ADR-024 |
| #34 — multiple Control Planes and scheduling relays | ADR-017 |
| #35 — content-addressed frontend distribution | ADR-021 |
| #36 — decentralized identity, governance, validator operations | ADR-023 |
Expand Down
40 changes: 33 additions & 7 deletions control-plane/cmd/controlplane-admin/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Command controlplane-admin is operator tooling for two unrelated
// privileged actions that both need the Control Plane's own credentials
// rather than an ordinary user's or validator's:
// Command controlplane-admin is operator tooling for privileged actions
// that need the Control Plane's own credentials rather than an ordinary
// user's or validator's:
//
// - User/API-key management (issue #12): there is deliberately no
// self-service user registration RPC (that would be a much larger
Expand All @@ -10,6 +10,10 @@
// exactly once, to stdout, and never persisted anywhere (only its
// SHA-256 hash lives in Postgres) -- copy it immediately; there is no
// way to recover it later, only to revoke it and issue a new one.
// - grant-role (ADR-016 §4, issue #76): the only way a user becomes a
// dashboard operator (or is demoted back to tenant). Same trust
// boundary as create-user/issue-key -- there is no self-service way
// for a user to grant themselves elevated dashboard access.
// - resolve-dispute (ADR-013 slice 5, issue #78): pallet-network-
// validator's resolve_dispute extrinsic is SuspensionOrigin-gated
// (EnsureRoot in this runtime) -- only the Control Plane's own
Expand All @@ -20,8 +24,8 @@
// account.
//
// Each subcommand only connects to the credential store it actually
// needs -- user/API-key commands never touch the chain, resolve-dispute
// never touches Postgres.
// needs -- user/API-key/role commands never touch the chain,
// resolve-dispute never touches Postgres.
package main

import (
Expand Down Expand Up @@ -53,7 +57,7 @@ func run(args []string) error {
ctx := context.Background()

switch args[0] {
case "create-user", "issue-key", "revoke-key":
case "create-user", "issue-key", "revoke-key", "grant-role":
return runUserCommand(ctx, args)
case "resolve-dispute":
return runResolveDispute(ctx, args)
Expand Down Expand Up @@ -97,6 +101,11 @@ func runUserCommand(ctx context.Context, args []string) error {
}
fmt.Println("revoked")
return nil
case "grant-role":
if len(args) != 3 {
return errors.New("usage: controlplane-admin grant-role <user-id> <tenant|operator>")
}
return grantRole(ctx, repository, args[1], args[2])
}
return usageError()
}
Expand All @@ -120,6 +129,23 @@ func issueKey(ctx context.Context, repository *userauth.PostgresRepository, user
return nil
}

// grantRole is ADR-016 §4's break-glass grant path: the only way a user
// becomes (or stops being) a dashboard operator. Deliberately the same
// operational shape as create-user/issue-key/revoke-key -- an offline
// Postgres write requiring this binary's own DATABASE_URL access, not a
// self-service RPC a logged-in user could call on themselves or anyone
// else.
func grantRole(ctx context.Context, repository *userauth.PostgresRepository, userID, role string) error {
if !userauth.ValidRole(role) {
return fmt.Errorf("%q must be exactly %q or %q", role, userauth.RoleTenant, userauth.RoleOperator)
}
if err := repository.SetRole(ctx, userID, role); err != nil {
return fmt.Errorf("grant role: %w", err)
}
fmt.Printf("user %s is now role=%s\n", userID, role)
return nil
}

// runResolveDispute settles a dispute pallet-network-validator's
// resolve_dispute -- see the package doc comment for why this, uniquely
// among this session's Network Validator tooling, must run with the
Expand Down Expand Up @@ -197,5 +223,5 @@ func parseUpholdOrReject(value string) (bool, error) {
}

func usageError() error {
return errors.New("usage: controlplane-admin <create-user <display-name> | issue-key <user-id> | revoke-key <key-id> | resolve-dispute <provider-hex> <round> <dimension> <uphold|reject>>")
return errors.New("usage: controlplane-admin <create-user <display-name> | issue-key <user-id> | revoke-key <key-id> | grant-role <user-id> <tenant|operator> | resolve-dispute <provider-hex> <round> <dimension> <uphold|reject>>")
}
22 changes: 18 additions & 4 deletions control-plane/internal/dashboard/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,20 +128,34 @@ func (s *Server) authIssueAPIKey(w http.ResponseWriter, r *http.Request) {
}

func (s *Server) authenticatedUserID(ctx context.Context, r *http.Request) (string, bool) {
user, ok := s.authenticatedUser(ctx, r)
if !ok {
return "", false
}
return user.UserID, true
}

// authenticatedUser is authenticatedUserID's superset: the full
// userauth.User (including Role, ADR-016), for callers that need more
// than just the ID -- currently only requireRole (rbac.go), which needs
// Role. Kept as the one bearer-token-parsing implementation rather than
// duplicating it, with authenticatedUserID as a thin wrapper so its
// existing callers/behavior are unchanged.
func (s *Server) authenticatedUser(ctx context.Context, r *http.Request) (userauth.User, bool) {
const prefix = "Bearer "
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, prefix) {
return "", false
return userauth.User{}, false
}
raw := strings.TrimPrefix(header, prefix)
if raw == "" {
return "", false
return userauth.User{}, false
}
user, err := s.users.Authenticate(ctx, userauth.HashAPIKey(raw))
if err != nil {
return "", false
return userauth.User{}, false
}
return user.UserID, true
return user, true
}

// allowRate applies the per-endpoint abuse rate limit, keyed by caller
Expand Down
48 changes: 48 additions & 0 deletions control-plane/internal/dashboard/rbac.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package dashboard

import (
"net/http"

"github.com/openinfra/network/internal/userauth"
)

// requireRole is ADR-016 slice 1's authorization enforcement point: wrap
// any handler that should only run for a caller holding a valid
// session/API key whose users.role is at least as privileged as minRole
// (userauth.RoleSatisfies -- an operator satisfies a "tenant"
// requirement too, see that function's doc comment). Intended to be
// applied once per route at registration time (Server.Handler in
// dashboard.go), the same place every route is already declared, so the
// entire dashboard authorization surface is auditable by reading one
// function rather than hunting for ad hoc checks inside individual
// handlers.
//
// Two distinct failure responses, not one generic "forbidden": 401 when
// there is no valid credential at all (the caller should log in), 403
// once a real identity is established but under-privileged for this
// route (the caller is who they say they are, just not allowed here) --
// collapsing these into one response would make "you're not logged in"
// indistinguishable from "you're logged in as the wrong role," which is
// a worse debugging experience for a legitimate caller and gives an
// attacker no useful signal either way (both already require a valid
// credential to get past the first check).
//
// Slice 1 introduces this function with zero routes wrapped in it yet --
// see ADR-016's Sequencing section. It is still real, tested code: a
// later slice's diff is "wrap the new route in requireRole," not "invent
// and test this function for the first time under deadline pressure."
func (s *Server) requireRole(minRole string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, ok := s.authenticatedUser(ctx, r)
if !ok {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
if !userauth.RoleSatisfies(user.Role, minRole) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "insufficient role"})
return
}
next(w, r)
}
}
164 changes: 164 additions & 0 deletions control-plane/internal/dashboard/rbac_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package dashboard

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/openinfra/network/internal/userauth"
)

// issueSessionKey is a small test helper: creates a user with the given
// role and returns a raw API key that authenticates as them --
// deliberately going through the real userauth.PostgresRepository (via
// newAuthTestServer), not a fake, so these tests exercise requireRole
// against the actual Authenticate/Role round trip a real request would.
func issueSessionKey(t *testing.T, server *Server, role string) string {
t.Helper()
user, err := server.users.CreateUser(t.Context(), "rbac-test-user")
if err != nil {
t.Fatal(err)
}
if role != userauth.RoleTenant {
if err := server.users.SetRole(t.Context(), user.UserID, role); err != nil {
t.Fatal(err)
}
}
key, err := server.users.CreateAPIKey(t.Context(), user.UserID)
if err != nil {
t.Fatal(err)
}
return key.Raw
}

func TestRequireRoleRejectsAnUnauthenticatedCaller(t *testing.T) {
_, server, _ := newAuthTestServer(t)
handler := server.requireRole(userauth.RoleTenant, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("the wrapped handler must not run for an unauthenticated caller")
})

request := httptest.NewRequest(http.MethodGet, "/protected", nil)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)

if recorder.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", recorder.Code)
}
}

func TestRequireRoleAllowsATenantThroughATenantGate(t *testing.T) {
_, server, _ := newAuthTestServer(t)
rawKey := issueSessionKey(t, server, userauth.RoleTenant)

ran := false
handler := server.requireRole(userauth.RoleTenant, func(w http.ResponseWriter, r *http.Request) {
ran = true
w.WriteHeader(http.StatusOK)
})

request := httptest.NewRequest(http.MethodGet, "/protected", nil)
request.Header.Set("Authorization", "Bearer "+rawKey)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)

if !ran {
t.Fatal("expected the wrapped handler to run for a tenant at a tenant gate")
}
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", recorder.Code)
}
}

func TestRequireRoleRejectsATenantAtAnOperatorGate(t *testing.T) {
_, server, _ := newAuthTestServer(t)
rawKey := issueSessionKey(t, server, userauth.RoleTenant)

handler := server.requireRole(userauth.RoleOperator, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("the wrapped handler must not run for an under-privileged caller")
})

request := httptest.NewRequest(http.MethodGet, "/protected", nil)
request.Header.Set("Authorization", "Bearer "+rawKey)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)

// 403, not 401 -- the caller has a real, valid credential; they are
// simply the wrong role. See requireRole's doc comment for why this
// distinction matters.
if recorder.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", recorder.Code)
}
}

func TestRequireRoleAllowsAnOperatorThroughATenantGate(t *testing.T) {
_, server, _ := newAuthTestServer(t)
rawKey := issueSessionKey(t, server, userauth.RoleOperator)

ran := false
handler := server.requireRole(userauth.RoleTenant, func(w http.ResponseWriter, r *http.Request) {
ran = true
w.WriteHeader(http.StatusOK)
})

request := httptest.NewRequest(http.MethodGet, "/protected", nil)
request.Header.Set("Authorization", "Bearer "+rawKey)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)

if !ran {
t.Fatal("expected an operator to satisfy a tenant-tier gate too (ADR-016 §1's ranked roles)")
}
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", recorder.Code)
}
}

func TestRequireRoleAllowsAnOperatorThroughAnOperatorGate(t *testing.T) {
_, server, _ := newAuthTestServer(t)
rawKey := issueSessionKey(t, server, userauth.RoleOperator)

ran := false
handler := server.requireRole(userauth.RoleOperator, func(w http.ResponseWriter, r *http.Request) {
ran = true
w.WriteHeader(http.StatusOK)
})

request := httptest.NewRequest(http.MethodGet, "/protected", nil)
request.Header.Set("Authorization", "Bearer "+rawKey)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)

if !ran {
t.Fatal("expected an operator to satisfy an operator-tier gate")
}
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", recorder.Code)
}
}

func TestRequireRoleRejectsARevokedKey(t *testing.T) {
_, server, _ := newAuthTestServer(t)
user, err := server.users.CreateUser(t.Context(), "rbac-test-user")
if err != nil {
t.Fatal(err)
}
key, err := server.users.CreateAPIKey(t.Context(), user.UserID)
if err != nil {
t.Fatal(err)
}
if err := server.users.RevokeAPIKey(t.Context(), key.KeyID); err != nil {
t.Fatal(err)
}

handler := server.requireRole(userauth.RoleTenant, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("the wrapped handler must not run for a revoked key")
})
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
request.Header.Set("Authorization", "Bearer "+key.Raw)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)

if recorder.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", recorder.Code)
}
}
3 changes: 2 additions & 1 deletion control-plane/internal/userauth/interceptor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ func (r fakeRepository) CreateAPIKey(context.Context, string) (userauth.APIKey,
func (r fakeRepository) CreateAPIKeyWithExpiry(context.Context, string, *time.Time) (userauth.APIKey, error) {
panic("unused")
}
func (r fakeRepository) RevokeAPIKey(context.Context, string) error { panic("unused") }
func (r fakeRepository) RevokeAPIKey(context.Context, string) error { panic("unused") }
func (r fakeRepository) SetRole(context.Context, string, string) error { panic("unused") }
func (r fakeRepository) Authenticate(_ context.Context, hash [32]byte) (userauth.User, error) {
if r.err != nil {
return userauth.User{}, r.err
Expand Down
Loading