Add policy CRUD, cert management, and web UI updates
- Add PUT /v1/policy/rule endpoint for updating policy rules; expose full policy CRUD through the web UI with a dedicated policy page - Add certificate revoke, delete, and get-cert to CA engine and wire REST + gRPC routes; fix missing interceptor registrations - Update ARCHITECTURE.md to reflect v2 gRPC as the active implementation, document ACME endpoints, correct CA permission levels, and add policy/cert management route tables - Add POLICY.md documenting the priority-based ACL engine design - Add web/templates/policy.html for policy management UI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -48,8 +48,9 @@ type DatabaseConfig struct {
|
||||
|
||||
// MCIASConfig holds MCIAS integration settings.
|
||||
type MCIASConfig struct {
|
||||
ServerURL string `toml:"server_url"`
|
||||
CACert string `toml:"ca_cert"`
|
||||
ServerURL string `toml:"server_url"`
|
||||
CACert string `toml:"ca_cert"`
|
||||
ServiceToken string `toml:"service_token"`
|
||||
}
|
||||
|
||||
// SealConfig holds Argon2id parameters for the seal process.
|
||||
|
||||
@@ -637,6 +637,9 @@ func (e *CAEngine) handleListIssuers(_ context.Context, req *engine.Request) (*e
|
||||
if req.CallerInfo == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if !req.CallerInfo.IsUser() {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
@@ -661,6 +664,9 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin
|
||||
if req.CallerInfo == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if !req.CallerInfo.IsAdmin {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
issuerName, _ := req.Data["issuer"].(string)
|
||||
if issuerName == "" {
|
||||
@@ -810,6 +816,9 @@ func (e *CAEngine) handleGetCert(ctx context.Context, req *engine.Request) (*eng
|
||||
if req.CallerInfo == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if !req.CallerInfo.IsUser() {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
serial, _ := req.Data["serial"].(string)
|
||||
if serial == "" {
|
||||
@@ -858,6 +867,9 @@ func (e *CAEngine) handleListCerts(ctx context.Context, req *engine.Request) (*e
|
||||
if req.CallerInfo == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if !req.CallerInfo.IsUser() {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
@@ -902,6 +914,9 @@ func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engin
|
||||
if req.CallerInfo == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if !req.CallerInfo.IsAdmin {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
serial, _ := req.Data["serial"].(string)
|
||||
if serial == "" {
|
||||
@@ -1028,6 +1043,9 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng
|
||||
if req.CallerInfo == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if !req.CallerInfo.IsAdmin {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
issuerName, _ := req.Data["issuer"].(string)
|
||||
if issuerName == "" {
|
||||
|
||||
@@ -76,6 +76,10 @@ func userCaller() *engine.CallerInfo {
|
||||
return &engine.CallerInfo{Username: "user", Roles: []string{"user"}, IsAdmin: false}
|
||||
}
|
||||
|
||||
func guestCaller() *engine.CallerInfo {
|
||||
return &engine.CallerInfo{Username: "guest", Roles: []string{"guest"}, IsAdmin: false}
|
||||
}
|
||||
|
||||
func setupEngine(t *testing.T) (*CAEngine, *memBarrier) {
|
||||
t.Helper()
|
||||
b := newMemBarrier()
|
||||
@@ -166,7 +170,7 @@ func TestInitializeWithImportedRoot(t *testing.T) {
|
||||
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userCaller(),
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "imported.example.com",
|
||||
@@ -313,7 +317,7 @@ func TestIssueCertificate(t *testing.T) {
|
||||
resp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
Path: "infra",
|
||||
CallerInfo: userCaller(),
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "web.example.com",
|
||||
@@ -375,7 +379,7 @@ func TestIssueCertificateWithOverrides(t *testing.T) {
|
||||
// Issue with custom TTL and key usages.
|
||||
resp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userCaller(),
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "peer.example.com",
|
||||
@@ -433,6 +437,169 @@ func TestIssueRejectsNilCallerInfo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueRejectsNonAdmin(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "create-issuer",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{"name": "infra"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-issuer: %v", err)
|
||||
}
|
||||
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "test.example.com",
|
||||
"profile": "server",
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("expected ErrForbidden, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewRejectsNonAdmin(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "create-issuer",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{"name": "infra"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-issuer: %v", err)
|
||||
}
|
||||
|
||||
issueResp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "test.example.com",
|
||||
"profile": "server",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
|
||||
serial := issueResp.Data["serial"].(string) //nolint:errcheck
|
||||
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "renew",
|
||||
CallerInfo: userCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"serial": serial,
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("expected ErrForbidden, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignCSRRejectsNonAdmin(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "create-issuer",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{"name": "infra"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-issuer: %v", err)
|
||||
}
|
||||
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "sign-csr",
|
||||
CallerInfo: userCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"csr_pem": "dummy",
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("expected ErrForbidden for user, got: %v", err)
|
||||
}
|
||||
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "sign-csr",
|
||||
CallerInfo: guestCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"csr_pem": "dummy",
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("expected ErrForbidden for guest, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListIssuersRejectsGuest(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "list-issuers",
|
||||
CallerInfo: guestCaller(),
|
||||
})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("expected ErrForbidden for guest, got: %v", err)
|
||||
}
|
||||
|
||||
// user and admin should succeed
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "list-issuers",
|
||||
CallerInfo: userCaller(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("expected user to list issuers, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCertRejectsGuest(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "get-cert",
|
||||
CallerInfo: guestCaller(),
|
||||
Data: map[string]interface{}{"serial": "abc123"},
|
||||
})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("expected ErrForbidden for guest, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCertsRejectsGuest(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "list-certs",
|
||||
CallerInfo: guestCaller(),
|
||||
})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("expected ErrForbidden for guest, got: %v", err)
|
||||
}
|
||||
|
||||
// user should succeed
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "list-certs",
|
||||
CallerInfo: userCaller(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("expected user to list certs, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateKeyNotStoredInBarrier(t *testing.T) {
|
||||
eng, b := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
@@ -448,7 +615,7 @@ func TestPrivateKeyNotStoredInBarrier(t *testing.T) {
|
||||
|
||||
resp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userCaller(),
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "test.example.com",
|
||||
@@ -487,7 +654,7 @@ func TestRenewCertificate(t *testing.T) {
|
||||
// Issue original cert.
|
||||
issueResp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userCaller(),
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "renew.example.com",
|
||||
@@ -504,7 +671,7 @@ func TestRenewCertificate(t *testing.T) {
|
||||
// Renew.
|
||||
renewResp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "renew",
|
||||
CallerInfo: userCaller(),
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"serial": origSerial,
|
||||
},
|
||||
@@ -545,7 +712,7 @@ func TestGetAndListCerts(t *testing.T) {
|
||||
for _, cn := range []string{"a.example.com", "b.example.com"} {
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userCaller(),
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": cn,
|
||||
@@ -622,7 +789,7 @@ func TestUnsealRestoresIssuers(t *testing.T) {
|
||||
// Verify we can issue from the restored issuer.
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userCaller(),
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "after-unseal.example.com",
|
||||
|
||||
@@ -37,6 +37,19 @@ type CallerInfo struct {
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// IsUser returns true if the caller has the "user" or "admin" role (i.e. not guest-only).
|
||||
func (c *CallerInfo) IsUser() bool {
|
||||
if c.IsAdmin {
|
||||
return true
|
||||
}
|
||||
for _, r := range c.Roles {
|
||||
if r == "user" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Request is a request to an engine.
|
||||
type Request struct {
|
||||
Data map[string]interface{}
|
||||
|
||||
@@ -269,6 +269,25 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
info := TokenInfoFromContext(r.Context())
|
||||
|
||||
// Evaluate policy before dispatching to the engine.
|
||||
policyReq := &policy.Request{
|
||||
Username: info.Username,
|
||||
Roles: info.Roles,
|
||||
Resource: "engine/" + req.Mount + "/" + req.Operation,
|
||||
Action: operationAction(req.Operation),
|
||||
}
|
||||
effect, err := s.policy.Evaluate(r.Context(), policyReq)
|
||||
if err != nil {
|
||||
s.logger.Error("policy evaluation failed", "error", err)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if effect != policy.EffectAllow {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
engReq := &engine.Request{
|
||||
Operation: req.Operation,
|
||||
Path: req.Path,
|
||||
@@ -547,6 +566,16 @@ func (s *Server) getCAEngine(mountName string) (*ca.CAEngine, error) {
|
||||
return caEng, nil
|
||||
}
|
||||
|
||||
// operationAction maps an engine operation name to a policy action ("read" or "write").
|
||||
func operationAction(op string) string {
|
||||
switch op {
|
||||
case "list-issuers", "list-certs", "get-cert", "get-root", "get-chain", "get-issuer":
|
||||
return "read"
|
||||
default:
|
||||
return "write"
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
@@ -163,6 +163,100 @@ func TestRootNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func unsealServer(t *testing.T, sealMgr *seal.Manager, _ interface{}) {
|
||||
t.Helper()
|
||||
params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1}
|
||||
if err := sealMgr.Initialize(context.Background(), []byte("password"), params); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func makeEngineRequest(mount, operation string) string {
|
||||
return `{"mount":"` + mount + `","operation":"` + operation + `","data":{}}`
|
||||
}
|
||||
|
||||
func withTokenInfo(r *http.Request, info *auth.TokenInfo) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), tokenInfoKey, info))
|
||||
}
|
||||
|
||||
// TestEngineRequestPolicyDeniesNonAdmin verifies that a non-admin user without
|
||||
// an explicit allow rule is denied by the policy engine.
|
||||
func TestEngineRequestPolicyDeniesNonAdmin(t *testing.T) {
|
||||
srv, sealMgr, _ := setupTestServer(t)
|
||||
unsealServer(t, sealMgr, nil)
|
||||
|
||||
body := makeEngineRequest("pki", "list-issuers")
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
|
||||
req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false})
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleEngineRequest(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 Forbidden for non-admin without policy rule, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngineRequestPolicyAllowsAdmin verifies that admin users bypass policy.
|
||||
func TestEngineRequestPolicyAllowsAdmin(t *testing.T) {
|
||||
srv, sealMgr, _ := setupTestServer(t)
|
||||
unsealServer(t, sealMgr, nil)
|
||||
|
||||
body := makeEngineRequest("pki", "list-issuers")
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
|
||||
req = withTokenInfo(req, &auth.TokenInfo{Username: "admin", Roles: []string{"admin"}, IsAdmin: true})
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleEngineRequest(w, req)
|
||||
|
||||
// Admin bypasses policy; will fail with mount-not-found (404), not forbidden (403).
|
||||
if w.Code == http.StatusForbidden {
|
||||
t.Errorf("admin should not be forbidden by policy, got 403: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngineRequestPolicyAllowsWithRule verifies that a non-admin user with an
|
||||
// explicit allow rule is permitted to proceed.
|
||||
func TestEngineRequestPolicyAllowsWithRule(t *testing.T) {
|
||||
srv, sealMgr, _ := setupTestServer(t)
|
||||
unsealServer(t, sealMgr, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
_ = srv.policy.CreateRule(ctx, &policy.Rule{
|
||||
ID: "allow-user-read",
|
||||
Priority: 100,
|
||||
Effect: policy.EffectAllow,
|
||||
Roles: []string{"user"},
|
||||
Resources: []string{"engine/*/*"},
|
||||
Actions: []string{"read"},
|
||||
})
|
||||
|
||||
body := makeEngineRequest("pki", "list-issuers")
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
|
||||
req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false})
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleEngineRequest(w, req)
|
||||
|
||||
// Policy allows; will fail with mount-not-found (404), not forbidden (403).
|
||||
if w.Code == http.StatusForbidden {
|
||||
t.Errorf("user with allow rule should not be forbidden, got 403: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestOperationAction verifies the read/write classification of operations.
|
||||
func TestOperationAction(t *testing.T) {
|
||||
readOps := []string{"list-issuers", "list-certs", "get-cert", "get-root", "get-chain", "get-issuer"}
|
||||
for _, op := range readOps {
|
||||
if got := operationAction(op); got != "read" {
|
||||
t.Errorf("operationAction(%q) = %q, want %q", op, got, "read")
|
||||
}
|
||||
}
|
||||
writeOps := []string{"issue", "renew", "create-issuer", "delete-issuer", "sign-csr", "revoke"}
|
||||
for _, op := range writeOps {
|
||||
if got := operationAction(op); got != "write" {
|
||||
t.Errorf("operationAction(%q) = %q, want %q", op, got, "write")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenInfoFromContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if info := TokenInfoFromContext(ctx); info != nil {
|
||||
|
||||
@@ -110,6 +110,20 @@ func (m *mockVault) DeleteCert(ctx context.Context, token, mount, serial string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockVault) ListPolicies(ctx context.Context, token string) ([]PolicyRule, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockVault) GetPolicy(ctx context.Context, token, id string) (*PolicyRule, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockVault) CreatePolicy(ctx context.Context, token string, rule PolicyRule) (*PolicyRule, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockVault) DeletePolicy(ctx context.Context, token, id string) error { return nil }
|
||||
|
||||
func (m *mockVault) Close() error { return nil }
|
||||
|
||||
// ---- handleTGZDownload tests ----
|
||||
|
||||
@@ -23,6 +23,7 @@ type VaultClient struct {
|
||||
engine pb.EngineServiceClient
|
||||
pki pb.PKIServiceClient
|
||||
ca pb.CAServiceClient
|
||||
policy pb.PolicyServiceClient
|
||||
}
|
||||
|
||||
// NewVaultClient dials the vault gRPC server and returns a client.
|
||||
@@ -60,6 +61,7 @@ func NewVaultClient(addr, caCertPath string, logger *slog.Logger) (*VaultClient,
|
||||
engine: pb.NewEngineServiceClient(conn),
|
||||
pki: pb.NewPKIServiceClient(conn),
|
||||
ca: pb.NewCAServiceClient(conn),
|
||||
policy: pb.NewPolicyServiceClient(conn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -379,6 +381,85 @@ func (c *VaultClient) DeleteCert(ctx context.Context, token, mount, serial strin
|
||||
return err
|
||||
}
|
||||
|
||||
// PolicyRule holds a policy rule for display and management.
|
||||
type PolicyRule struct {
|
||||
ID string
|
||||
Priority int
|
||||
Effect string
|
||||
Usernames []string
|
||||
Roles []string
|
||||
Resources []string
|
||||
Actions []string
|
||||
}
|
||||
|
||||
// ListPolicies returns all policy rules from the vault.
|
||||
func (c *VaultClient) ListPolicies(ctx context.Context, token string) ([]PolicyRule, error) {
|
||||
resp, err := c.policy.ListPolicies(withToken(ctx, token), &pb.ListPoliciesRequest{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules := make([]PolicyRule, 0, len(resp.Rules))
|
||||
for _, r := range resp.Rules {
|
||||
rules = append(rules, pbToRule(r))
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// GetPolicy retrieves a single policy rule by ID.
|
||||
func (c *VaultClient) GetPolicy(ctx context.Context, token, id string) (*PolicyRule, error) {
|
||||
resp, err := c.policy.GetPolicy(withToken(ctx, token), &pb.GetPolicyRequest{Id: id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rule := pbToRule(resp.Rule)
|
||||
return &rule, nil
|
||||
}
|
||||
|
||||
// CreatePolicy creates a new policy rule.
|
||||
func (c *VaultClient) CreatePolicy(ctx context.Context, token string, rule PolicyRule) (*PolicyRule, error) {
|
||||
resp, err := c.policy.CreatePolicy(withToken(ctx, token), &pb.CreatePolicyRequest{
|
||||
Rule: ruleToPB(rule),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
created := pbToRule(resp.Rule)
|
||||
return &created, nil
|
||||
}
|
||||
|
||||
// DeletePolicy removes a policy rule by ID.
|
||||
func (c *VaultClient) DeletePolicy(ctx context.Context, token, id string) error {
|
||||
_, err := c.policy.DeletePolicy(withToken(ctx, token), &pb.DeletePolicyRequest{Id: id})
|
||||
return err
|
||||
}
|
||||
|
||||
func pbToRule(r *pb.PolicyRule) PolicyRule {
|
||||
if r == nil {
|
||||
return PolicyRule{}
|
||||
}
|
||||
return PolicyRule{
|
||||
ID: r.Id,
|
||||
Priority: int(r.Priority),
|
||||
Effect: r.Effect,
|
||||
Usernames: r.Usernames,
|
||||
Roles: r.Roles,
|
||||
Resources: r.Resources,
|
||||
Actions: r.Actions,
|
||||
}
|
||||
}
|
||||
|
||||
func ruleToPB(r PolicyRule) *pb.PolicyRule {
|
||||
return &pb.PolicyRule{
|
||||
Id: r.ID,
|
||||
Priority: int32(r.Priority),
|
||||
Effect: r.Effect,
|
||||
Usernames: r.Usernames,
|
||||
Roles: r.Roles,
|
||||
Resources: r.Resources,
|
||||
Actions: r.Actions,
|
||||
}
|
||||
}
|
||||
|
||||
// CertSummary holds lightweight certificate metadata for list views.
|
||||
type CertSummary struct {
|
||||
Serial string
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -38,6 +39,12 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
|
||||
r.Get("/dashboard", ws.requireAuth(ws.handleDashboard))
|
||||
r.Post("/dashboard/mount-ca", ws.requireAuth(ws.handleDashboardMountCA))
|
||||
|
||||
r.Route("/policy", func(r chi.Router) {
|
||||
r.Get("/", ws.requireAuth(ws.handlePolicy))
|
||||
r.Post("/", ws.requireAuth(ws.handlePolicyCreate))
|
||||
r.Post("/delete", ws.requireAuth(ws.handlePolicyDelete))
|
||||
})
|
||||
|
||||
r.Route("/pki", func(r chi.Router) {
|
||||
r.Get("/", ws.requireAuth(ws.handlePKI))
|
||||
r.Post("/import-root", ws.requireAuth(ws.handleImportRoot))
|
||||
@@ -71,6 +78,7 @@ func (ws *WebServer) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
info.Username = ws.resolveUser(info.Username)
|
||||
r = r.WithContext(withTokenInfo(r.Context(), info))
|
||||
next(w, r)
|
||||
}
|
||||
@@ -469,6 +477,10 @@ func (ws *WebServer) handleIssuerDetail(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range certs {
|
||||
certs[i].IssuedBy = ws.resolveUser(certs[i].IssuedBy)
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Username": info.Username,
|
||||
"IsAdmin": info.IsAdmin,
|
||||
@@ -625,6 +637,8 @@ func (ws *WebServer) handleCertDetail(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cert.IssuedBy = ws.resolveUser(cert.IssuedBy)
|
||||
cert.RevokedBy = ws.resolveUser(cert.RevokedBy)
|
||||
ws.renderTemplate(w, "cert_detail.html", map[string]interface{}{
|
||||
"Username": info.Username,
|
||||
"IsAdmin": info.IsAdmin,
|
||||
@@ -822,6 +836,104 @@ func (ws *WebServer) findCAMount(r *http.Request, token string) (string, error)
|
||||
return "", fmt.Errorf("no CA engine mounted")
|
||||
}
|
||||
|
||||
func (ws *WebServer) handlePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
info := tokenInfoFromContext(r.Context())
|
||||
if !info.IsAdmin {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
token := extractCookie(r)
|
||||
rules, err := ws.vault.ListPolicies(r.Context(), token)
|
||||
if err != nil {
|
||||
rules = []PolicyRule{}
|
||||
}
|
||||
ws.renderTemplate(w, "policy.html", map[string]interface{}{
|
||||
"Username": info.Username,
|
||||
"IsAdmin": info.IsAdmin,
|
||||
"Rules": rules,
|
||||
})
|
||||
}
|
||||
|
||||
func (ws *WebServer) handlePolicyCreate(w http.ResponseWriter, r *http.Request) {
|
||||
info := tokenInfoFromContext(r.Context())
|
||||
if !info.IsAdmin {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
token := extractCookie(r)
|
||||
_ = r.ParseForm()
|
||||
|
||||
priorityStr := r.FormValue("priority")
|
||||
priority := 50
|
||||
if priorityStr != "" {
|
||||
if p, err := strconv.Atoi(priorityStr); err == nil {
|
||||
priority = p
|
||||
}
|
||||
}
|
||||
|
||||
splitCSV := func(s string) []string {
|
||||
var out []string
|
||||
for _, v := range strings.Split(s, ",") {
|
||||
v = strings.TrimSpace(v)
|
||||
if v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
rule := PolicyRule{
|
||||
ID: r.FormValue("id"),
|
||||
Priority: priority,
|
||||
Effect: r.FormValue("effect"),
|
||||
Usernames: splitCSV(r.FormValue("usernames")),
|
||||
Roles: splitCSV(r.FormValue("roles")),
|
||||
Resources: splitCSV(r.FormValue("resources")),
|
||||
Actions: splitCSV(r.FormValue("actions")),
|
||||
}
|
||||
|
||||
if rule.ID == "" || rule.Effect == "" {
|
||||
ws.renderPolicyWithError(w, r, info, token, "ID and effect are required")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := ws.vault.CreatePolicy(r.Context(), token, rule); err != nil {
|
||||
ws.renderPolicyWithError(w, r, info, token, grpcMessage(err))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/policy", http.StatusFound)
|
||||
}
|
||||
|
||||
func (ws *WebServer) handlePolicyDelete(w http.ResponseWriter, r *http.Request) {
|
||||
info := tokenInfoFromContext(r.Context())
|
||||
if !info.IsAdmin {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
token := extractCookie(r)
|
||||
_ = r.ParseForm()
|
||||
id := r.FormValue("id")
|
||||
if id == "" {
|
||||
http.Redirect(w, r, "/policy", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := ws.vault.DeletePolicy(r.Context(), token, id); err != nil {
|
||||
ws.renderPolicyWithError(w, r, info, token, grpcMessage(err))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/policy", http.StatusFound)
|
||||
}
|
||||
|
||||
func (ws *WebServer) renderPolicyWithError(w http.ResponseWriter, r *http.Request, info *TokenInfo, token, errMsg string) {
|
||||
rules, _ := ws.vault.ListPolicies(r.Context(), token)
|
||||
ws.renderTemplate(w, "policy.html", map[string]interface{}{
|
||||
"Username": info.Username,
|
||||
"IsAdmin": info.IsAdmin,
|
||||
"Rules": rules,
|
||||
"Error": errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
// grpcMessage extracts a human-readable message from a gRPC error.
|
||||
func grpcMessage(err error) string {
|
||||
if st, ok := status.FromError(err); ok {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
||||
webui "git.wntrmute.dev/kyle/metacrypt/web"
|
||||
)
|
||||
@@ -40,23 +41,69 @@ type vaultBackend interface {
|
||||
ListCerts(ctx context.Context, token, mount string) ([]CertSummary, error)
|
||||
RevokeCert(ctx context.Context, token, mount, serial string) error
|
||||
DeleteCert(ctx context.Context, token, mount, serial string) error
|
||||
ListPolicies(ctx context.Context, token string) ([]PolicyRule, error)
|
||||
GetPolicy(ctx context.Context, token, id string) (*PolicyRule, error)
|
||||
CreatePolicy(ctx context.Context, token string, rule PolicyRule) (*PolicyRule, error)
|
||||
DeletePolicy(ctx context.Context, token, id string) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
const userCacheTTL = 5 * time.Minute
|
||||
|
||||
// tgzEntry holds a cached tgz archive pending download.
|
||||
type tgzEntry struct {
|
||||
filename string
|
||||
data []byte
|
||||
}
|
||||
|
||||
// cachedUsername holds a resolved UUID→username entry with an expiry.
|
||||
type cachedUsername struct {
|
||||
username string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// WebServer is the standalone web UI server.
|
||||
type WebServer struct {
|
||||
cfg *config.Config
|
||||
vault vaultBackend
|
||||
logger *slog.Logger
|
||||
httpSrv *http.Server
|
||||
staticFS fs.FS
|
||||
tgzCache sync.Map // key: UUID string → *tgzEntry
|
||||
cfg *config.Config
|
||||
vault vaultBackend
|
||||
mcias *mcias.Client // optional; nil when no service_token is configured
|
||||
logger *slog.Logger
|
||||
httpSrv *http.Server
|
||||
staticFS fs.FS
|
||||
tgzCache sync.Map // key: UUID string → *tgzEntry
|
||||
userCache sync.Map // key: UUID string → *cachedUsername
|
||||
}
|
||||
|
||||
// resolveUser returns the display name for a user ID. If the ID is already a
|
||||
// human-readable username (i.e. not a UUID), it is returned unchanged. When the
|
||||
// webserver has an MCIAS client configured it will look up unknown IDs and cache
|
||||
// the result; otherwise the raw ID is returned as a fallback.
|
||||
func (ws *WebServer) resolveUser(id string) string {
|
||||
if id == "" {
|
||||
return id
|
||||
}
|
||||
if v, ok := ws.userCache.Load(id); ok {
|
||||
if entry := v.(*cachedUsername); time.Now().Before(entry.expiresAt) {
|
||||
ws.logger.Info("webserver: resolved user ID from cache", "id", id, "username", entry.username)
|
||||
return entry.username
|
||||
}
|
||||
}
|
||||
if ws.mcias == nil {
|
||||
ws.logger.Warn("webserver: no MCIAS client available, cannot resolve user ID", "id", id)
|
||||
return id
|
||||
}
|
||||
ws.logger.Info("webserver: looking up user ID via MCIAS", "id", id)
|
||||
acct, err := ws.mcias.GetAccount(id)
|
||||
if err != nil {
|
||||
ws.logger.Warn("webserver: failed to resolve user ID", "id", id, "error", err)
|
||||
return id
|
||||
}
|
||||
ws.logger.Info("webserver: resolved user ID", "id", id, "username", acct.Username)
|
||||
ws.userCache.Store(id, &cachedUsername{
|
||||
username: acct.Username,
|
||||
expiresAt: time.Now().Add(userCacheTTL),
|
||||
})
|
||||
return acct.Username
|
||||
}
|
||||
|
||||
// New creates a new WebServer. It dials the vault gRPC endpoint.
|
||||
@@ -73,12 +120,35 @@ func New(cfg *config.Config, logger *slog.Logger) (*WebServer, error) {
|
||||
return nil, fmt.Errorf("webserver: static FS: %w", err)
|
||||
}
|
||||
|
||||
return &WebServer{
|
||||
ws := &WebServer{
|
||||
cfg: cfg,
|
||||
vault: vault,
|
||||
logger: logger,
|
||||
staticFS: staticFS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if tok := cfg.MCIAS.ServiceToken; tok != "" {
|
||||
mc, err := mcias.New(cfg.MCIAS.ServerURL, mcias.Options{
|
||||
CACertPath: cfg.MCIAS.CACert,
|
||||
Token: tok,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("webserver: failed to create MCIAS client for user resolution", "error", err)
|
||||
} else {
|
||||
claims, err := mc.ValidateToken(tok)
|
||||
switch {
|
||||
case err != nil:
|
||||
logger.Warn("webserver: MCIAS service token validation failed", "error", err)
|
||||
case !claims.Valid:
|
||||
logger.Warn("webserver: MCIAS service token is invalid or expired")
|
||||
default:
|
||||
logger.Info("webserver: MCIAS service token valid", "sub", claims.Sub, "roles", claims.Roles)
|
||||
ws.mcias = mc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
// loggingMiddleware logs each incoming HTTP request.
|
||||
|
||||
Reference in New Issue
Block a user