- Add auth/login and auth/logout to mciasgrpcctl, calling the existing AuthService.Login/Logout RPCs; password is always prompted interactively (term.ReadPassword), never accepted as a flag, raw bytes zeroed after use - Add proto/mcias/v1/policy.proto with PolicyService (List, Create, Get, Update, Delete policy rules) - Regenerate gen/mcias/v1/ stubs to include policy - Implement internal/grpcserver/policyservice.go delegating to the same db layer as the REST policy handlers - Register PolicyService in grpcserver.go - Add policy list/create/get/update/delete to mciasgrpcctl - Update mciasgrpcctl man page with new commands Security: auth login uses the same interactive password prompt pattern as mciasctl; password never appears in process args, shell history, or logs; raw bytes zeroed after string conversion (same as REST CLI and REST server). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
279 lines
9.0 KiB
Go
279 lines
9.0 KiB
Go
// policyServiceServer implements mciasv1.PolicyServiceServer.
|
|
// All handlers are admin-only and delegate to the same db package used by
|
|
// the REST policy handlers in internal/server/handlers_policy.go.
|
|
package grpcserver
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
|
|
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
|
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
|
)
|
|
|
|
type policyServiceServer struct {
|
|
mciasv1.UnimplementedPolicyServiceServer
|
|
s *Server
|
|
}
|
|
|
|
// policyRuleToProto converts a model.PolicyRuleRecord to the wire representation.
|
|
func policyRuleToProto(rec *model.PolicyRuleRecord) *mciasv1.PolicyRule {
|
|
r := &mciasv1.PolicyRule{
|
|
Id: rec.ID,
|
|
Description: rec.Description,
|
|
Priority: int32(rec.Priority), //nolint:gosec // priority is a small positive integer
|
|
Enabled: rec.Enabled,
|
|
RuleJson: rec.RuleJSON,
|
|
CreatedAt: rec.CreatedAt.UTC().Format(time.RFC3339),
|
|
UpdatedAt: rec.UpdatedAt.UTC().Format(time.RFC3339),
|
|
}
|
|
if rec.NotBefore != nil {
|
|
r.NotBefore = rec.NotBefore.UTC().Format(time.RFC3339)
|
|
}
|
|
if rec.ExpiresAt != nil {
|
|
r.ExpiresAt = rec.ExpiresAt.UTC().Format(time.RFC3339)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// validateRuleJSON ensures the JSON string is valid and contains a recognised
|
|
// effect. It mirrors the validation in the REST handleCreatePolicyRule handler.
|
|
func validateRuleJSON(ruleJSON string) error {
|
|
var body policy.RuleBody
|
|
if err := json.Unmarshal([]byte(ruleJSON), &body); err != nil {
|
|
return fmt.Errorf("rule_json is not valid JSON: %w", err)
|
|
}
|
|
if body.Effect != policy.Allow && body.Effect != policy.Deny {
|
|
return fmt.Errorf("rule.effect must be %q or %q", policy.Allow, policy.Deny)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListPolicyRules returns all policy rules. Admin only.
|
|
func (p *policyServiceServer) ListPolicyRules(ctx context.Context, _ *mciasv1.ListPolicyRulesRequest) (*mciasv1.ListPolicyRulesResponse, error) {
|
|
if err := p.s.requireAdmin(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rules, err := p.s.db.ListPolicyRules(false)
|
|
if err != nil {
|
|
p.s.logger.Error("list policy rules", "error", err)
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
resp := &mciasv1.ListPolicyRulesResponse{
|
|
Rules: make([]*mciasv1.PolicyRule, 0, len(rules)),
|
|
}
|
|
for _, rec := range rules {
|
|
resp.Rules = append(resp.Rules, policyRuleToProto(rec))
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// CreatePolicyRule creates a new policy rule. Admin only.
|
|
func (p *policyServiceServer) CreatePolicyRule(ctx context.Context, req *mciasv1.CreatePolicyRuleRequest) (*mciasv1.CreatePolicyRuleResponse, error) {
|
|
if err := p.s.requireAdmin(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if req.Description == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "description is required")
|
|
}
|
|
if req.RuleJson == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "rule_json is required")
|
|
}
|
|
if err := validateRuleJSON(req.RuleJson); err != nil {
|
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
|
}
|
|
|
|
priority := int(req.Priority)
|
|
if priority == 0 {
|
|
priority = 100 // default, matching REST handler
|
|
}
|
|
|
|
var notBefore, expiresAt *time.Time
|
|
if req.NotBefore != "" {
|
|
t, err := time.Parse(time.RFC3339, req.NotBefore)
|
|
if err != nil {
|
|
return nil, status.Error(codes.InvalidArgument, "not_before must be RFC3339")
|
|
}
|
|
notBefore = &t
|
|
}
|
|
if req.ExpiresAt != "" {
|
|
t, err := time.Parse(time.RFC3339, req.ExpiresAt)
|
|
if err != nil {
|
|
return nil, status.Error(codes.InvalidArgument, "expires_at must be RFC3339")
|
|
}
|
|
expiresAt = &t
|
|
}
|
|
if notBefore != nil && expiresAt != nil && !expiresAt.After(*notBefore) {
|
|
return nil, status.Error(codes.InvalidArgument, "expires_at must be after not_before")
|
|
}
|
|
|
|
claims := claimsFromContext(ctx)
|
|
var createdBy *int64
|
|
if claims != nil {
|
|
if actor, err := p.s.db.GetAccountByUUID(claims.Subject); err == nil {
|
|
createdBy = &actor.ID
|
|
}
|
|
}
|
|
|
|
rec, err := p.s.db.CreatePolicyRule(req.Description, priority, req.RuleJson, createdBy, notBefore, expiresAt)
|
|
if err != nil {
|
|
p.s.logger.Error("create policy rule", "error", err)
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
p.s.db.WriteAuditEvent(model.EventPolicyRuleCreated, createdBy, nil, peerIP(ctx), //nolint:errcheck
|
|
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
|
|
|
return &mciasv1.CreatePolicyRuleResponse{Rule: policyRuleToProto(rec)}, nil
|
|
}
|
|
|
|
// GetPolicyRule returns a single policy rule by ID. Admin only.
|
|
func (p *policyServiceServer) GetPolicyRule(ctx context.Context, req *mciasv1.GetPolicyRuleRequest) (*mciasv1.GetPolicyRuleResponse, error) {
|
|
if err := p.s.requireAdmin(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Id == 0 {
|
|
return nil, status.Error(codes.InvalidArgument, "id is required")
|
|
}
|
|
|
|
rec, err := p.s.db.GetPolicyRule(req.Id)
|
|
if err != nil {
|
|
if errors.Is(err, db.ErrNotFound) {
|
|
return nil, status.Error(codes.NotFound, "policy rule not found")
|
|
}
|
|
p.s.logger.Error("get policy rule", "error", err)
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
return &mciasv1.GetPolicyRuleResponse{Rule: policyRuleToProto(rec)}, nil
|
|
}
|
|
|
|
// UpdatePolicyRule applies a partial update to a policy rule. Admin only.
|
|
func (p *policyServiceServer) UpdatePolicyRule(ctx context.Context, req *mciasv1.UpdatePolicyRuleRequest) (*mciasv1.UpdatePolicyRuleResponse, error) {
|
|
if err := p.s.requireAdmin(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Id == 0 {
|
|
return nil, status.Error(codes.InvalidArgument, "id is required")
|
|
}
|
|
|
|
// Verify the rule exists before applying updates.
|
|
if _, err := p.s.db.GetPolicyRule(req.Id); err != nil {
|
|
if errors.Is(err, db.ErrNotFound) {
|
|
return nil, status.Error(codes.NotFound, "policy rule not found")
|
|
}
|
|
p.s.logger.Error("get policy rule for update", "error", err)
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
// Build optional update fields — nil means "do not change".
|
|
var priority *int
|
|
if req.Priority != nil {
|
|
v := int(req.GetPriority())
|
|
priority = &v
|
|
}
|
|
|
|
// Double-pointer semantics for time fields: nil outer = no change;
|
|
// non-nil outer with nil inner = set to NULL; non-nil both = set value.
|
|
var notBefore, expiresAt **time.Time
|
|
if req.ClearNotBefore {
|
|
var nilTime *time.Time
|
|
notBefore = &nilTime
|
|
} else if req.NotBefore != "" {
|
|
t, err := time.Parse(time.RFC3339, req.NotBefore)
|
|
if err != nil {
|
|
return nil, status.Error(codes.InvalidArgument, "not_before must be RFC3339")
|
|
}
|
|
tp := &t
|
|
notBefore = &tp
|
|
}
|
|
if req.ClearExpiresAt {
|
|
var nilTime *time.Time
|
|
expiresAt = &nilTime
|
|
} else if req.ExpiresAt != "" {
|
|
t, err := time.Parse(time.RFC3339, req.ExpiresAt)
|
|
if err != nil {
|
|
return nil, status.Error(codes.InvalidArgument, "expires_at must be RFC3339")
|
|
}
|
|
tp := &t
|
|
expiresAt = &tp
|
|
}
|
|
|
|
if err := p.s.db.UpdatePolicyRule(req.Id, nil, priority, nil, notBefore, expiresAt); err != nil {
|
|
p.s.logger.Error("update policy rule", "error", err)
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
if req.Enabled != nil {
|
|
if err := p.s.db.SetPolicyRuleEnabled(req.Id, req.GetEnabled()); err != nil {
|
|
p.s.logger.Error("set policy rule enabled", "error", err)
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
}
|
|
|
|
claims := claimsFromContext(ctx)
|
|
var actorID *int64
|
|
if claims != nil {
|
|
if actor, err := p.s.db.GetAccountByUUID(claims.Subject); err == nil {
|
|
actorID = &actor.ID
|
|
}
|
|
}
|
|
p.s.db.WriteAuditEvent(model.EventPolicyRuleUpdated, actorID, nil, peerIP(ctx), //nolint:errcheck
|
|
fmt.Sprintf(`{"rule_id":%d}`, req.Id))
|
|
|
|
updated, err := p.s.db.GetPolicyRule(req.Id)
|
|
if err != nil {
|
|
p.s.logger.Error("get updated policy rule", "error", err)
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
return &mciasv1.UpdatePolicyRuleResponse{Rule: policyRuleToProto(updated)}, nil
|
|
}
|
|
|
|
// DeletePolicyRule permanently removes a policy rule. Admin only.
|
|
func (p *policyServiceServer) DeletePolicyRule(ctx context.Context, req *mciasv1.DeletePolicyRuleRequest) (*mciasv1.DeletePolicyRuleResponse, error) {
|
|
if err := p.s.requireAdmin(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Id == 0 {
|
|
return nil, status.Error(codes.InvalidArgument, "id is required")
|
|
}
|
|
|
|
rec, err := p.s.db.GetPolicyRule(req.Id)
|
|
if err != nil {
|
|
if errors.Is(err, db.ErrNotFound) {
|
|
return nil, status.Error(codes.NotFound, "policy rule not found")
|
|
}
|
|
p.s.logger.Error("get policy rule for delete", "error", err)
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
if err := p.s.db.DeletePolicyRule(req.Id); err != nil {
|
|
p.s.logger.Error("delete policy rule", "error", err)
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
claims := claimsFromContext(ctx)
|
|
var actorID *int64
|
|
if claims != nil {
|
|
if actor, err := p.s.db.GetAccountByUUID(claims.Subject); err == nil {
|
|
actorID = &actor.ID
|
|
}
|
|
}
|
|
p.s.db.WriteAuditEvent(model.EventPolicyRuleDeleted, actorID, nil, peerIP(ctx), //nolint:errcheck
|
|
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
|
|
|
return &mciasv1.DeletePolicyRuleResponse{}, nil
|
|
}
|