Files
metacrypt/internal/webserver/client.go
Kyle Isom fbd6d1af04 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>
2026-03-15 19:41:11 -07:00

499 lines
14 KiB
Go

package webserver
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"log/slog"
"os"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2"
)
// VaultClient wraps the gRPC stubs for communicating with the vault.
type VaultClient struct {
conn *grpc.ClientConn
system pb.SystemServiceClient
auth pb.AuthServiceClient
engine pb.EngineServiceClient
pki pb.PKIServiceClient
ca pb.CAServiceClient
policy pb.PolicyServiceClient
}
// NewVaultClient dials the vault gRPC server and returns a client.
func NewVaultClient(addr, caCertPath string, logger *slog.Logger) (*VaultClient, error) {
logger.Debug("connecting to vault", "addr", addr, "ca_cert", caCertPath)
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
if caCertPath != "" {
logger.Debug("loading vault CA certificate", "path", caCertPath)
pemData, err := os.ReadFile(caCertPath) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("webserver: read CA cert: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pemData) {
return nil, fmt.Errorf("webserver: parse CA cert")
}
tlsCfg.RootCAs = pool
logger.Debug("vault CA certificate loaded successfully")
} else {
logger.Debug("no CA cert configured, using system roots")
}
logger.Debug("dialing vault gRPC", "addr", addr)
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)))
if err != nil {
return nil, fmt.Errorf("webserver: dial vault: %w", err)
}
logger.Debug("vault gRPC connection established", "addr", addr)
return &VaultClient{
conn: conn,
system: pb.NewSystemServiceClient(conn),
auth: pb.NewAuthServiceClient(conn),
engine: pb.NewEngineServiceClient(conn),
pki: pb.NewPKIServiceClient(conn),
ca: pb.NewCAServiceClient(conn),
policy: pb.NewPolicyServiceClient(conn),
}, nil
}
// Close closes the underlying connection.
func (c *VaultClient) Close() error {
return c.conn.Close()
}
// withToken returns a context with the Bearer token in outgoing metadata.
func withToken(ctx context.Context, token string) context.Context {
return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token)
}
// Status returns the current vault state string (e.g. "unsealed").
func (c *VaultClient) Status(ctx context.Context) (string, error) {
resp, err := c.system.Status(ctx, &pb.StatusRequest{})
if err != nil {
return "", err
}
return resp.State, nil
}
// Init initializes the vault with the given password.
func (c *VaultClient) Init(ctx context.Context, password string) error {
_, err := c.system.Init(ctx, &pb.InitRequest{Password: password})
return err
}
// Unseal unseals the vault with the given password.
func (c *VaultClient) Unseal(ctx context.Context, password string) error {
_, err := c.system.Unseal(ctx, &pb.UnsealRequest{Password: password})
return err
}
// TokenInfo holds validated token details returned by the vault.
type TokenInfo struct {
Username string
Roles []string
IsAdmin bool
}
// Login authenticates against the vault and returns the session token.
func (c *VaultClient) Login(ctx context.Context, username, password, totpCode string) (string, error) {
resp, err := c.auth.Login(ctx, &pb.LoginRequest{
Username: username,
Password: password,
TotpCode: totpCode,
})
if err != nil {
return "", err
}
return resp.Token, nil
}
// ValidateToken validates a token against the vault and returns the token info.
func (c *VaultClient) ValidateToken(ctx context.Context, token string) (*TokenInfo, error) {
resp, err := c.auth.TokenInfo(withToken(ctx, token), &pb.TokenInfoRequest{})
if err != nil {
return nil, err
}
return &TokenInfo{
Username: resp.Username,
Roles: resp.Roles,
IsAdmin: resp.IsAdmin,
}, nil
}
// MountInfo holds metadata about an engine mount.
type MountInfo struct {
Name string
Type string
MountPath string
}
// ListMounts returns all engine mounts from the vault.
func (c *VaultClient) ListMounts(ctx context.Context, token string) ([]MountInfo, error) {
resp, err := c.engine.ListMounts(withToken(ctx, token), &pb.ListMountsRequest{})
if err != nil {
return nil, err
}
mounts := make([]MountInfo, 0, len(resp.Mounts))
for _, m := range resp.Mounts {
mounts = append(mounts, MountInfo{
Name: m.Name,
Type: m.Type,
MountPath: m.MountPath,
})
}
return mounts, nil
}
// Mount creates a new engine mount on the vault.
func (c *VaultClient) Mount(ctx context.Context, token, name, engineType string, config map[string]interface{}) error {
req := &pb.MountRequest{
Name: name,
Type: engineType,
}
if len(config) > 0 {
cfg := make(map[string]string, len(config))
for k, v := range config {
cfg[k] = fmt.Sprintf("%v", v)
}
req.Config = cfg
}
_, err := c.engine.Mount(withToken(ctx, token), req)
return err
}
// GetRootCert returns the root CA certificate PEM for the given mount.
func (c *VaultClient) GetRootCert(ctx context.Context, mount string) ([]byte, error) {
resp, err := c.pki.GetRootCert(ctx, &pb.GetRootCertRequest{Mount: mount})
if err != nil {
return nil, err
}
return resp.CertPem, nil
}
// GetIssuerCert returns a named issuer certificate PEM for the given mount.
func (c *VaultClient) GetIssuerCert(ctx context.Context, mount, issuer string) ([]byte, error) {
resp, err := c.pki.GetIssuerCert(ctx, &pb.GetIssuerCertRequest{Mount: mount, Issuer: issuer})
if err != nil {
return nil, err
}
return resp.CertPem, nil
}
// ImportRoot imports an existing root CA certificate and key into the given mount.
func (c *VaultClient) ImportRoot(ctx context.Context, token, mount, certPEM, keyPEM string) error {
_, err := c.ca.ImportRoot(withToken(ctx, token), &pb.ImportRootRequest{
Mount: mount,
CertPem: []byte(certPEM),
KeyPem: []byte(keyPEM),
})
return err
}
// CreateIssuerRequest holds parameters for creating an intermediate CA issuer.
type CreateIssuerRequest struct {
Mount string
Name string
KeyAlgorithm string
KeySize int32
Expiry string
MaxTTL string
}
// CreateIssuer creates a new intermediate CA issuer on the given mount.
func (c *VaultClient) CreateIssuer(ctx context.Context, token string, req CreateIssuerRequest) error {
_, err := c.ca.CreateIssuer(withToken(ctx, token), &pb.CreateIssuerRequest{
Mount: req.Mount,
Name: req.Name,
KeyAlgorithm: req.KeyAlgorithm,
KeySize: req.KeySize,
Expiry: req.Expiry,
MaxTtl: req.MaxTTL,
})
return err
}
// ListIssuers returns the names of all issuers for the given mount.
func (c *VaultClient) ListIssuers(ctx context.Context, token, mount string) ([]string, error) {
resp, err := c.ca.ListIssuers(withToken(ctx, token), &pb.ListIssuersRequest{Mount: mount})
if err != nil {
return nil, err
}
return resp.Issuers, nil
}
// IssueCertRequest holds parameters for issuing a leaf certificate.
type IssueCertRequest struct {
Mount string
Issuer string
Profile string
CommonName string
DNSNames []string
IPAddresses []string
TTL string
KeyUsages []string
ExtKeyUsages []string
}
// IssuedCert holds the result of a certificate issuance.
type IssuedCert struct {
Serial string
CertPEM string
KeyPEM string
ChainPEM string
ExpiresAt string
}
// IssueCert issues a new leaf certificate from the named issuer.
func (c *VaultClient) IssueCert(ctx context.Context, token string, req IssueCertRequest) (*IssuedCert, error) {
resp, err := c.ca.IssueCert(withToken(ctx, token), &pb.IssueCertRequest{
Mount: req.Mount,
Issuer: req.Issuer,
Profile: req.Profile,
CommonName: req.CommonName,
DnsNames: req.DNSNames,
IpAddresses: req.IPAddresses,
Ttl: req.TTL,
KeyUsages: req.KeyUsages,
ExtKeyUsages: req.ExtKeyUsages,
})
if err != nil {
return nil, err
}
issued := &IssuedCert{
Serial: resp.Serial,
CertPEM: string(resp.CertPem),
KeyPEM: string(resp.KeyPem),
ChainPEM: string(resp.ChainPem),
}
if resp.ExpiresAt != nil {
issued.ExpiresAt = resp.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
}
return issued, nil
}
// SignCSRRequest holds parameters for signing an external CSR.
type SignCSRRequest struct {
Mount string
Issuer string
CSRPEM string
Profile string
TTL string
}
// SignedCert holds the result of signing a CSR.
type SignedCert struct {
Serial string
CertPEM string
ChainPEM string
ExpiresAt string
}
// SignCSR signs an externally generated CSR with the named issuer.
func (c *VaultClient) SignCSR(ctx context.Context, token string, req SignCSRRequest) (*SignedCert, error) {
resp, err := c.ca.SignCSR(withToken(ctx, token), &pb.SignCSRRequest{
Mount: req.Mount,
Issuer: req.Issuer,
CsrPem: []byte(req.CSRPEM),
Profile: req.Profile,
Ttl: req.TTL,
})
if err != nil {
return nil, err
}
sc := &SignedCert{
Serial: resp.Serial,
CertPEM: string(resp.CertPem),
ChainPEM: string(resp.ChainPem),
}
if resp.ExpiresAt != nil {
sc.ExpiresAt = resp.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
}
return sc, nil
}
// CertDetail holds the full certificate record for the detail view.
type CertDetail struct {
Serial string
Issuer string
CommonName string
SANs []string
Profile string
IssuedBy string
IssuedAt string
ExpiresAt string
CertPEM string
Revoked bool
RevokedAt string
RevokedBy string
}
// GetCert retrieves a full certificate record by serial number.
func (c *VaultClient) GetCert(ctx context.Context, token, mount, serial string) (*CertDetail, error) {
resp, err := c.ca.GetCert(withToken(ctx, token), &pb.GetCertRequest{Mount: mount, Serial: serial})
if err != nil {
return nil, err
}
rec := resp.GetCert()
if rec == nil {
return nil, fmt.Errorf("cert not found")
}
cd := &CertDetail{
Serial: rec.Serial,
Issuer: rec.Issuer,
CommonName: rec.CommonName,
SANs: rec.Sans,
Profile: rec.Profile,
IssuedBy: rec.IssuedBy,
CertPEM: string(rec.CertPem),
Revoked: rec.Revoked,
RevokedBy: rec.RevokedBy,
}
if rec.IssuedAt != nil {
cd.IssuedAt = rec.IssuedAt.AsTime().Format("2006-01-02T15:04:05Z")
}
if rec.ExpiresAt != nil {
cd.ExpiresAt = rec.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
}
if rec.RevokedAt != nil {
cd.RevokedAt = rec.RevokedAt.AsTime().Format("2006-01-02T15:04:05Z")
}
return cd, nil
}
// RevokeCert marks a certificate as revoked.
func (c *VaultClient) RevokeCert(ctx context.Context, token, mount, serial string) error {
_, err := c.ca.RevokeCert(withToken(ctx, token), &pb.RevokeCertRequest{Mount: mount, Serial: serial})
return err
}
// DeleteCert permanently removes a certificate record.
func (c *VaultClient) DeleteCert(ctx context.Context, token, mount, serial string) error {
_, err := c.ca.DeleteCert(withToken(ctx, token), &pb.DeleteCertRequest{Mount: mount, Serial: serial})
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
Issuer string
CommonName string
Profile string
IssuedBy string
IssuedAt string
ExpiresAt string
}
// ListCerts returns all certificate summaries for the given mount.
func (c *VaultClient) ListCerts(ctx context.Context, token, mount string) ([]CertSummary, error) {
resp, err := c.ca.ListCerts(withToken(ctx, token), &pb.ListCertsRequest{Mount: mount})
if err != nil {
return nil, err
}
certs := make([]CertSummary, 0, len(resp.Certs))
for _, s := range resp.Certs {
cs := CertSummary{
Serial: s.Serial,
Issuer: s.Issuer,
CommonName: s.CommonName,
Profile: s.Profile,
IssuedBy: s.IssuedBy,
}
if s.IssuedAt != nil {
cs.IssuedAt = s.IssuedAt.AsTime().Format("2006-01-02T15:04:05Z")
}
if s.ExpiresAt != nil {
cs.ExpiresAt = s.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
}
certs = append(certs, cs)
}
return certs, nil
}