Merge branch 'worktree-agent-a98b5183'

# Conflicts:
#	cmd/metacrypt/server.go
#	internal/grpcserver/server.go
#	internal/server/routes.go
This commit is contained in:
2026-03-16 20:01:04 -07:00
10 changed files with 4031 additions and 47 deletions

View File

@@ -69,6 +69,17 @@ func (s *Server) registerRoutes(r chi.Router) {
r.Post("/v1/barrier/rotate-key", s.requireAdmin(s.handleRotateKey))
r.Post("/v1/barrier/migrate", s.requireAdmin(s.handleBarrierMigrate))
// User-to-user encryption routes (auth required).
r.Post("/v1/user/{mount}/register", s.requireAuth(s.handleUserRegister))
r.Post("/v1/user/{mount}/provision", s.requireAdmin(s.handleUserProvision))
r.Get("/v1/user/{mount}/keys", s.requireAuth(s.handleUserListUsers))
r.Get("/v1/user/{mount}/keys/{username}", s.requireAuth(s.handleUserGetPublicKey))
r.Delete("/v1/user/{mount}/keys/{username}", s.requireAdmin(s.handleUserDeleteUser))
r.Post("/v1/user/{mount}/encrypt", s.requireAuth(s.handleUserEncrypt))
r.Post("/v1/user/{mount}/decrypt", s.requireAuth(s.handleUserDecrypt))
r.Post("/v1/user/{mount}/re-encrypt", s.requireAuth(s.handleUserReEncrypt))
r.Post("/v1/user/{mount}/rotate", s.requireAuth(s.handleUserRotateKey))
r.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules))
r.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule))
@@ -298,30 +309,34 @@ func (s *Server) handleEngineUnmount(w http.ResponseWriter, r *http.Request) {
}
// adminOnlyOperations lists engine operations that require admin role.
// Keys are "engineType:operation" to avoid name collisions across engines
// (e.g. transit "rotate-key" is admin-only but user "rotate-key" is user-self).
// This enforces the same gates as the typed REST routes, ensuring the
// generic endpoint cannot bypass admin requirements.
var adminOnlyOperations = map[string]bool{
// CA engine.
"ca:import-root": true,
"ca:create-issuer": true,
"ca:delete-issuer": true,
"ca:revoke-cert": true,
"ca:delete-cert": true,
"import-root": true,
"create-issuer": true,
"delete-issuer": true,
"revoke-cert": true,
"delete-cert": true,
// Transit engine.
"transit:create-key": true,
"transit:delete-key": true,
"transit:rotate-key": true,
"transit:update-key-config": true,
"transit:trim-key": true,
"create-key": true,
"delete-key": true,
"rotate-key": true,
"update-key-config": true,
"trim-key": true,
// SSH CA engine.
"sshca:create-profile": true,
"sshca:update-profile": true,
"sshca:delete-profile": true,
"sshca:revoke-cert": true,
"sshca:delete-cert": true,
"create-profile": true,
"update-profile": true,
"delete-profile": true,
// User engine.
"user:provision": true,
"user:delete-user": true,
"provision": true,
"delete-user": true,
}
// adminExemptOperations lists engineType:operation pairs that are exempt from
// the unqualified admin-only check (e.g. user:rotate-key is user-self, not admin).
var adminExemptOperations = map[string]bool{
"user:rotate-key": true,
}
func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
@@ -342,17 +357,21 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
info := TokenInfoFromContext(r.Context())
// Resolve engine type from mount to qualify the admin-only lookup.
// Resolve engine type from mount.
mount, err := s.engines.GetMount(req.Mount)
if err != nil {
http.Error(w, `{"error":"mount not found"}`, http.StatusNotFound)
return
// Enforce admin requirement for operations that are admin-only.
// Check exemptions for engine-specific overrides (e.g. user:rotate-key is user-self).
if adminOnlyOperations[req.Operation] && !info.IsAdmin {
exempt := err == nil && adminExemptOperations[string(mount.Type)+":"+req.Operation]
if !exempt {
http.Error(w, `{"error":"forbidden: admin required"}`, http.StatusForbidden)
return
}
}
// Enforce admin requirement for operations that have admin-only typed routes.
// Key is "engineType:operation" to avoid cross-engine name collisions.
if adminOnlyOperations[string(mount.Type)+":"+req.Operation] && !info.IsAdmin {
http.Error(w, `{"error":"forbidden: admin required"}`, http.StatusForbidden)
if err != nil {
http.Error(w, `{"error":"mount not found"}`, http.StatusNotFound)
return
}
@@ -803,24 +822,7 @@ func (s *Server) transitRequest(w http.ResponseWriter, r *http.Request, mount, o
Data: data,
})
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, engine.ErrMountNotFound):
status = http.StatusNotFound
case strings.Contains(err.Error(), "forbidden"):
status = http.StatusForbidden
case strings.Contains(err.Error(), "authentication required"):
status = http.StatusUnauthorized
case strings.Contains(err.Error(), "not found"):
status = http.StatusNotFound
case strings.Contains(err.Error(), "not allowed"):
status = http.StatusForbidden
case strings.Contains(err.Error(), "unsupported"):
status = http.StatusBadRequest
case strings.Contains(err.Error(), "invalid"):
status = http.StatusBadRequest
}
http.Error(w, `{"error":"`+err.Error()+`"}`, status)
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
@@ -1033,6 +1035,239 @@ func (s *Server) handleTransitGetPublicKey(w http.ResponseWriter, r *http.Reques
s.transitRequest(w, r, mount, "get-public-key", map[string]interface{}{"name": name})
}
// --- User-to-User Encryption Handlers ---
func (s *Server) handleUserRegister(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "register",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleUserProvision(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
var req struct {
Username string `json:"username"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Username == "" {
http.Error(w, `{"error":"username is required"}`, http.StatusBadRequest)
return
}
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "provision",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"username": req.Username},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleUserListUsers(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "list-users",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleUserGetPublicKey(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
username := chi.URLParam(r, "username")
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "get-public-key",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"username": username},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleUserDeleteUser(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
username := chi.URLParam(r, "username")
info := TokenInfoFromContext(r.Context())
_, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "delete-user",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"username": username},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
}
func (s *Server) handleUserEncrypt(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
var req struct {
Plaintext string `json:"plaintext"`
Metadata string `json:"metadata"`
Recipients []string `json:"recipients"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
recipients := make([]interface{}, len(req.Recipients))
for i, r := range req.Recipients {
recipients[i] = r
}
info := TokenInfoFromContext(r.Context())
policyChecker := func(resource, action string) (string, bool) {
pReq := &policy.Request{
Username: info.Username,
Roles: info.Roles,
Resource: resource,
Action: action,
}
eff, matched, pErr := s.policy.Match(r.Context(), pReq)
if pErr != nil {
return string(policy.EffectDeny), false
}
return string(eff), matched
}
data := map[string]interface{}{
"plaintext": req.Plaintext,
"recipients": recipients,
}
if req.Metadata != "" {
data["metadata"] = req.Metadata
}
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "encrypt",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: policyChecker,
Data: data,
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleUserDecrypt(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
var req struct {
Envelope string `json:"envelope"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "decrypt",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"envelope": req.Envelope},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleUserReEncrypt(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
var req struct {
Envelope string `json:"envelope"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "re-encrypt",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"envelope": req.Envelope},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleUserRotateKey(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "rotate-key",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
// operationAction maps an engine operation name to a policy action.
func operationAction(op string) string {
switch op {
@@ -1045,7 +1280,7 @@ func operationAction(op string) string {
// Granular cryptographic operations (including batch variants).
case "encrypt", "batch-encrypt":
return policy.ActionEncrypt
case "decrypt", "batch-decrypt", "rewrap", "batch-rewrap":
case "decrypt", "batch-decrypt", "rewrap", "batch-rewrap", "re-encrypt":
return policy.ActionDecrypt
case "sign", "sign-host", "sign-user":
return policy.ActionSign
@@ -1481,10 +1716,17 @@ func (s *Server) writeEngineError(w http.ResponseWriter, err error) {
status = http.StatusForbidden
case errors.Is(err, sshca.ErrUnauthorized):
status = http.StatusUnauthorized
case strings.Contains(err.Error(), "forbidden"):
case strings.Contains(err.Error(), "forbidden"),
strings.Contains(err.Error(), "not allowed"):
status = http.StatusForbidden
case strings.Contains(err.Error(), "authentication required"):
status = http.StatusUnauthorized
case strings.Contains(err.Error(), "not found"):
status = http.StatusNotFound
case strings.Contains(err.Error(), "unsupported"),
strings.Contains(err.Error(), "invalid"),
strings.Contains(err.Error(), "too many"):
status = http.StatusBadRequest
}
http.Error(w, `{"error":"`+err.Error()+`"}`, status)
}