Merge branch 'worktree-agent-a98b5183'
# Conflicts: # cmd/metacrypt/server.go # internal/grpcserver/server.go # internal/server/routes.go
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user