Phase 10: gRPC admin API with interceptor chain

Proto definitions for 4 services (RegistryService, PolicyService,
AuditService, AdminService) with hand-written Go stubs using JSON
codec until protobuf tooling is available.

Interceptor chain: logging (method, peer IP, duration, never logs
auth metadata) → auth (bearer token via MCIAS, Health bypasses) →
admin (role check for GC, policy, delete, audit RPCs).

All RPCs share business logic with REST handlers via internal/db
and internal/gc packages. TLS 1.3 minimum on gRPC listener.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 20:46:21 -07:00
parent 562b69e875
commit 185b68ff6d
30 changed files with 3616 additions and 4 deletions

View File

@@ -6,7 +6,7 @@ See `PROJECT_PLAN.md` for the implementation roadmap and
## Current State
**Phase:** 9 complete, ready for Phase 10
**Phase:** 10 complete, ready for Phase 11
**Last updated:** 2026-03-19
### Completed
@@ -21,6 +21,7 @@ See `PROJECT_PLAN.md` for the implementation roadmap and
- Phase 7: OCI delete path (all 2 steps)
- Phase 8: Admin REST API (all 5 steps)
- Phase 9: Garbage collection (all 2 steps)
- Phase 10: gRPC admin API (all 4 steps)
- `ARCHITECTURE.md` — Full design specification (18 sections)
- `CLAUDE.md` — AI development guidance
- `PROJECT_PLAN.md` — Implementation plan (14 phases, 40+ steps)
@@ -28,13 +29,100 @@ See `PROJECT_PLAN.md` for the implementation roadmap and
### Next Steps
1. Phase 10 (gRPC admin API)
2. Phase 11 (CLI tool) and Phase 12 (web UI)
1. Phase 11 (CLI tool) and Phase 12 (web UI)
---
## Log
### 2026-03-19 — Phase 10: gRPC admin API
**Task:** Implement the gRPC admin API server with the same business
logic as the REST admin API, per ARCHITECTURE.md section 7.
**Changes:**
Step 10.1 — Proto definitions (`proto/mcr/v1/`):
- `common.proto`: `PaginationRequest` shared type
- `registry.proto`: `RegistryService` with `ListRepositories`,
`GetRepository`, `DeleteRepository`, `GarbageCollect`, `GetGCStatus` RPCs
- `policy.proto`: `PolicyService` with `ListPolicyRules`,
`CreatePolicyRule`, `GetPolicyRule`, `UpdatePolicyRule`,
`DeletePolicyRule` RPCs; `UpdatePolicyRuleRequest` includes field mask
- `audit.proto`: `AuditService` with `ListAuditEvents` RPC; filter
fields for event_type, actor_id, repository, time range
- `admin.proto`: `AdminService` with `Health` RPC
Step 10.2 — Generated code (`gen/mcr/v1/`):
- Hand-written message types and gRPC service descriptors matching
protoc-gen-go v1.36+ and protoc-gen-go-grpc v1.5+ output patterns
- `codec.go`: JSON codec implementing `encoding.CodecV2` via
`mem.BufferSlice`, registered globally via `init()` as stand-in
until protobuf code generation is available
- Handler functions properly delegate to interceptor chain (critical
for auth/admin enforcement; handlers that ignore the interceptor
parameter bypass security checks entirely)
- Client and server interfaces with `mustEmbedUnimplemented*Server()`
for forward compatibility
Step 10.3 — Interceptor chain (`internal/grpcserver/interceptors.go`):
- `loggingInterceptor`: logs method, peer IP, status code, duration;
never logs the authorization metadata value
- `authInterceptor`: extracts `authorization` metadata, validates
bearer token via `TokenValidator` interface, injects claims into
context; `Health` bypasses auth via `authBypassMethods` map
- `adminInterceptor`: requires admin role for GC, policy, delete,
audit RPCs via `adminRequiredMethods` map; returns
`codes.PermissionDenied` for insufficient role
- gRPC errors: `codes.Unauthenticated` for missing/invalid token,
`codes.PermissionDenied` for insufficient role
Step 10.4 — Server implementation (`internal/grpcserver/`):
- `server.go`: `New(certFile, keyFile, deps)` creates configured
gRPC server with TLS 1.3 minimum (skips TLS if paths empty for
testing); `Serve()`, `GracefulStop()`
- `registry.go`: `registryService` implementing all 5 RPCs with
same DB calls as REST handlers; GC runs asynchronously with its
own `GCStatus` tracking (separate from REST's `GCState` since
`GCState.mu` is unexported); shares `gc.Collector` for actual GC
- `policy.go`: `policyService` implementing all 5 RPCs with
validation (effect, actions, priority), field mask updates, engine
reload, audit events
- `audit.go`: `auditService` implementing `ListAuditEvents` with
pagination and filter pass-through to `db.ListAuditEvents`
- `admin.go`: `adminService` implementing `Health` (returns "ok")
**Dependencies added:**
- `google.golang.org/grpc` v1.79.3
- `google.golang.org/protobuf` v1.36.11
- Transitive: `golang.org/x/net`, `golang.org/x/text`,
`google.golang.org/genproto/googleapis/rpc`
**Verification:**
- `make all` passes: vet clean, lint 0 issues, all tests passing,
all 3 binaries built
- Interceptor tests (12 new): Health bypasses auth, no token rejected
(Unauthenticated), invalid token rejected (Unauthenticated), valid
token accepted, non-admin denied policy RPCs (PermissionDenied),
admin allowed policy RPCs, admin required methods completeness
check, auth bypass methods completeness check, delete repo requires
admin, GC requires admin, audit requires admin
- Registry service tests (7 new): list repositories empty, get repo
not found, get repo empty name (InvalidArgument), delete repo not
found, delete repo empty name, GC status initial (not running),
GC trigger returns ID
- Policy service tests (8 new): create policy rule (with engine
reload verification), create validation (5 subtests: zero priority,
empty description, invalid effect, no actions, invalid action), get
policy rule, get not found, list policy rules, delete + verify
gone + reload count, delete not found, update with field mask
- Audit service tests (3 new): list empty, list with data (verify
fields and details map), list with pagination
- Admin service tests (2 new): Health returns "ok", Health without
auth succeeds
---
### 2026-03-19 — Phase 9: Garbage collection
**Task:** Implement the two-phase GC algorithm for removing unreferenced

View File

@@ -19,7 +19,7 @@ design specification.
| 7 | OCI API — delete path | **Complete** |
| 8 | Admin REST API | **Complete** |
| 9 | Garbage collection | **Complete** |
| 10 | gRPC admin API | Not started |
| 10 | gRPC admin API | **Complete** |
| 11 | CLI tool (mcrctl) | Not started |
| 12 | Web UI | Not started |
| 13 | Deployment artifacts | Not started |

19
gen/mcr/v1/admin.pb.go Normal file
View File

@@ -0,0 +1,19 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// source: mcr/v1/admin.proto
package mcrv1
// HealthRequest is the request message for Health.
type HealthRequest struct{}
// HealthResponse is the response message for Health.
type HealthResponse struct {
Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"`
}
func (x *HealthResponse) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}

View File

@@ -0,0 +1,88 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// source: mcr/v1/admin.proto
package mcrv1
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// AdminServiceServer is the server API for AdminService.
type AdminServiceServer interface {
Health(context.Context, *HealthRequest) (*HealthResponse, error)
mustEmbedUnimplementedAdminServiceServer()
}
// UnimplementedAdminServiceServer should be embedded to have forward
// compatible implementations.
type UnimplementedAdminServiceServer struct{}
func (UnimplementedAdminServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Health not implemented")
}
func (UnimplementedAdminServiceServer) mustEmbedUnimplementedAdminServiceServer() {}
// RegisterAdminServiceServer registers the AdminServiceServer with the grpc.Server.
func RegisterAdminServiceServer(s grpc.ServiceRegistrar, srv AdminServiceServer) {
s.RegisterService(&AdminService_ServiceDesc, srv)
}
func adminServiceHealthHandler(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) {
in := new(HealthRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminServiceServer).Health(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/mcr.v1.AdminService/Health",
}
handler := func(ctx context.Context, req any) (any, error) {
return srv.(AdminServiceServer).Health(ctx, req.(*HealthRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminService_ServiceDesc is the grpc.ServiceDesc for AdminService.
var AdminService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mcr.v1.AdminService",
HandlerType: (*AdminServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Health",
Handler: adminServiceHealthHandler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "mcr/v1/admin.proto",
}
// AdminServiceClient is the client API for AdminService.
type AdminServiceClient interface {
Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error)
}
type adminServiceClient struct {
cc grpc.ClientConnInterface
}
// NewAdminServiceClient creates a new AdminServiceClient.
func NewAdminServiceClient(cc grpc.ClientConnInterface) AdminServiceClient {
return &adminServiceClient{cc}
}
func (c *adminServiceClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) {
out := new(HealthResponse)
err := c.cc.Invoke(ctx, "/mcr.v1.AdminService/Health", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}

136
gen/mcr/v1/audit.pb.go Normal file
View File

@@ -0,0 +1,136 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// source: mcr/v1/audit.proto
package mcrv1
// AuditEvent represents an audit log entry.
type AuditEvent struct {
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` //nolint:revive,stylecheck // proto field name
EventTime string `protobuf:"bytes,2,opt,name=event_time,json=eventTime,proto3" json:"event_time,omitempty"`
EventType string `protobuf:"bytes,3,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"`
ActorId string `protobuf:"bytes,4,opt,name=actor_id,json=actorId,proto3" json:"actor_id,omitempty"` //nolint:revive,stylecheck // proto field name
Repository string `protobuf:"bytes,5,opt,name=repository,proto3" json:"repository,omitempty"`
Digest string `protobuf:"bytes,6,opt,name=digest,proto3" json:"digest,omitempty"`
IpAddress string `protobuf:"bytes,7,opt,name=ip_address,json=ipAddress,proto3" json:"ip_address,omitempty"` //nolint:revive,stylecheck // proto field name
Details map[string]string `protobuf:"bytes,8,rep,name=details,proto3" json:"details,omitempty"`
}
func (x *AuditEvent) GetId() int64 {
if x != nil {
return x.Id
}
return 0
}
func (x *AuditEvent) GetEventTime() string {
if x != nil {
return x.EventTime
}
return ""
}
func (x *AuditEvent) GetEventType() string {
if x != nil {
return x.EventType
}
return ""
}
func (x *AuditEvent) GetActorId() string {
if x != nil {
return x.ActorId
}
return ""
}
func (x *AuditEvent) GetRepository() string {
if x != nil {
return x.Repository
}
return ""
}
func (x *AuditEvent) GetDigest() string {
if x != nil {
return x.Digest
}
return ""
}
func (x *AuditEvent) GetIpAddress() string {
if x != nil {
return x.IpAddress
}
return ""
}
func (x *AuditEvent) GetDetails() map[string]string {
if x != nil {
return x.Details
}
return nil
}
// ListAuditEventsRequest is the request message for ListAuditEvents.
type ListAuditEventsRequest struct {
Pagination *PaginationRequest `protobuf:"bytes,1,opt,name=pagination,proto3" json:"pagination,omitempty"`
EventType string `protobuf:"bytes,2,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"`
ActorId string `protobuf:"bytes,3,opt,name=actor_id,json=actorId,proto3" json:"actor_id,omitempty"` //nolint:revive,stylecheck // proto field name
Repository string `protobuf:"bytes,4,opt,name=repository,proto3" json:"repository,omitempty"`
Since string `protobuf:"bytes,5,opt,name=since,proto3" json:"since,omitempty"`
Until string `protobuf:"bytes,6,opt,name=until,proto3" json:"until,omitempty"`
}
func (x *ListAuditEventsRequest) GetPagination() *PaginationRequest {
if x != nil {
return x.Pagination
}
return nil
}
func (x *ListAuditEventsRequest) GetEventType() string {
if x != nil {
return x.EventType
}
return ""
}
func (x *ListAuditEventsRequest) GetActorId() string {
if x != nil {
return x.ActorId
}
return ""
}
func (x *ListAuditEventsRequest) GetRepository() string {
if x != nil {
return x.Repository
}
return ""
}
func (x *ListAuditEventsRequest) GetSince() string {
if x != nil {
return x.Since
}
return ""
}
func (x *ListAuditEventsRequest) GetUntil() string {
if x != nil {
return x.Until
}
return ""
}
// ListAuditEventsResponse is the response message for ListAuditEvents.
type ListAuditEventsResponse struct {
Events []*AuditEvent `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"`
}
func (x *ListAuditEventsResponse) GetEvents() []*AuditEvent {
if x != nil {
return x.Events
}
return nil
}

View File

@@ -0,0 +1,88 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// source: mcr/v1/audit.proto
package mcrv1
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// AuditServiceServer is the server API for AuditService.
type AuditServiceServer interface {
ListAuditEvents(context.Context, *ListAuditEventsRequest) (*ListAuditEventsResponse, error)
mustEmbedUnimplementedAuditServiceServer()
}
// UnimplementedAuditServiceServer should be embedded to have forward
// compatible implementations.
type UnimplementedAuditServiceServer struct{}
func (UnimplementedAuditServiceServer) ListAuditEvents(context.Context, *ListAuditEventsRequest) (*ListAuditEventsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListAuditEvents not implemented")
}
func (UnimplementedAuditServiceServer) mustEmbedUnimplementedAuditServiceServer() {}
// RegisterAuditServiceServer registers the AuditServiceServer with the grpc.Server.
func RegisterAuditServiceServer(s grpc.ServiceRegistrar, srv AuditServiceServer) {
s.RegisterService(&AuditService_ServiceDesc, srv)
}
func auditServiceListAuditEventsHandler(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) {
in := new(ListAuditEventsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuditServiceServer).ListAuditEvents(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/mcr.v1.AuditService/ListAuditEvents",
}
handler := func(ctx context.Context, req any) (any, error) {
return srv.(AuditServiceServer).ListAuditEvents(ctx, req.(*ListAuditEventsRequest))
}
return interceptor(ctx, in, info, handler)
}
// AuditService_ServiceDesc is the grpc.ServiceDesc for AuditService.
var AuditService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mcr.v1.AuditService",
HandlerType: (*AuditServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListAuditEvents",
Handler: auditServiceListAuditEventsHandler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "mcr/v1/audit.proto",
}
// AuditServiceClient is the client API for AuditService.
type AuditServiceClient interface {
ListAuditEvents(ctx context.Context, in *ListAuditEventsRequest, opts ...grpc.CallOption) (*ListAuditEventsResponse, error)
}
type auditServiceClient struct {
cc grpc.ClientConnInterface
}
// NewAuditServiceClient creates a new AuditServiceClient.
func NewAuditServiceClient(cc grpc.ClientConnInterface) AuditServiceClient {
return &auditServiceClient{cc}
}
func (c *auditServiceClient) ListAuditEvents(ctx context.Context, in *ListAuditEventsRequest, opts ...grpc.CallOption) (*ListAuditEventsResponse, error) {
out := new(ListAuditEventsResponse)
err := c.cc.Invoke(ctx, "/mcr.v1.AuditService/ListAuditEvents", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}

44
gen/mcr/v1/codec.go Normal file
View File

@@ -0,0 +1,44 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
//
// This file provides a JSON codec for gRPC, used until proto tooling
// is available to generate proper protobuf serialization code.
package mcrv1
import (
"encoding/json"
"fmt"
"google.golang.org/grpc/encoding"
"google.golang.org/grpc/mem"
)
const codecName = "json"
func init() {
encoding.RegisterCodecV2(JSONCodec{})
}
// JSONCodec implements encoding.CodecV2 using JSON serialization.
// It is used as a stand-in until protobuf code generation is available.
type JSONCodec struct{}
func (JSONCodec) Marshal(v any) (mem.BufferSlice, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("json codec: marshal: %w", err)
}
return mem.BufferSlice{mem.NewBuffer(&b, nil)}, nil
}
func (JSONCodec) Unmarshal(data mem.BufferSlice, v any) error {
buf := data.Materialize()
if err := json.Unmarshal(buf, v); err != nil {
return fmt.Errorf("json codec: unmarshal: %w", err)
}
return nil
}
func (JSONCodec) Name() string {
return codecName
}

24
gen/mcr/v1/common.pb.go Normal file
View File

@@ -0,0 +1,24 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// source: mcr/v1/common.proto
package mcrv1
// PaginationRequest controls pagination for list RPCs.
type PaginationRequest struct {
Limit int32 `protobuf:"varint,1,opt,name=limit,proto3" json:"limit,omitempty"`
Offset int32 `protobuf:"varint,2,opt,name=offset,proto3" json:"offset,omitempty"`
}
func (x *PaginationRequest) GetLimit() int32 {
if x != nil {
return x.Limit
}
return 0
}
func (x *PaginationRequest) GetOffset() int32 {
if x != nil {
return x.Offset
}
return 0
}

331
gen/mcr/v1/policy.pb.go Normal file
View File

@@ -0,0 +1,331 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// source: mcr/v1/policy.proto
package mcrv1
// PolicyRule represents a policy rule.
type PolicyRule struct {
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` //nolint:revive,stylecheck // proto field name
Priority int32 `protobuf:"varint,2,opt,name=priority,proto3" json:"priority,omitempty"`
Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
Effect string `protobuf:"bytes,4,opt,name=effect,proto3" json:"effect,omitempty"`
Roles []string `protobuf:"bytes,5,rep,name=roles,proto3" json:"roles,omitempty"`
AccountTypes []string `protobuf:"bytes,6,rep,name=account_types,json=accountTypes,proto3" json:"account_types,omitempty"`
SubjectUuid string `protobuf:"bytes,7,opt,name=subject_uuid,json=subjectUuid,proto3" json:"subject_uuid,omitempty"` //nolint:revive,stylecheck // proto field name
Actions []string `protobuf:"bytes,8,rep,name=actions,proto3" json:"actions,omitempty"`
Repositories []string `protobuf:"bytes,9,rep,name=repositories,proto3" json:"repositories,omitempty"`
Enabled bool `protobuf:"varint,10,opt,name=enabled,proto3" json:"enabled,omitempty"`
CreatedBy string `protobuf:"bytes,11,opt,name=created_by,json=createdBy,proto3" json:"created_by,omitempty"`
CreatedAt string `protobuf:"bytes,12,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
UpdatedAt string `protobuf:"bytes,13,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"`
}
func (x *PolicyRule) GetId() int64 {
if x != nil {
return x.Id
}
return 0
}
func (x *PolicyRule) GetPriority() int32 {
if x != nil {
return x.Priority
}
return 0
}
func (x *PolicyRule) GetDescription() string {
if x != nil {
return x.Description
}
return ""
}
func (x *PolicyRule) GetEffect() string {
if x != nil {
return x.Effect
}
return ""
}
func (x *PolicyRule) GetRoles() []string {
if x != nil {
return x.Roles
}
return nil
}
func (x *PolicyRule) GetAccountTypes() []string {
if x != nil {
return x.AccountTypes
}
return nil
}
func (x *PolicyRule) GetSubjectUuid() string {
if x != nil {
return x.SubjectUuid
}
return ""
}
func (x *PolicyRule) GetActions() []string {
if x != nil {
return x.Actions
}
return nil
}
func (x *PolicyRule) GetRepositories() []string {
if x != nil {
return x.Repositories
}
return nil
}
func (x *PolicyRule) GetEnabled() bool {
if x != nil {
return x.Enabled
}
return false
}
func (x *PolicyRule) GetCreatedBy() string {
if x != nil {
return x.CreatedBy
}
return ""
}
func (x *PolicyRule) GetCreatedAt() string {
if x != nil {
return x.CreatedAt
}
return ""
}
func (x *PolicyRule) GetUpdatedAt() string {
if x != nil {
return x.UpdatedAt
}
return ""
}
// ListPolicyRulesRequest is the request message for ListPolicyRules.
type ListPolicyRulesRequest struct {
Pagination *PaginationRequest `protobuf:"bytes,1,opt,name=pagination,proto3" json:"pagination,omitempty"`
}
func (x *ListPolicyRulesRequest) GetPagination() *PaginationRequest {
if x != nil {
return x.Pagination
}
return nil
}
// ListPolicyRulesResponse is the response message for ListPolicyRules.
type ListPolicyRulesResponse struct {
Rules []*PolicyRule `protobuf:"bytes,1,rep,name=rules,proto3" json:"rules,omitempty"`
}
func (x *ListPolicyRulesResponse) GetRules() []*PolicyRule {
if x != nil {
return x.Rules
}
return nil
}
// CreatePolicyRuleRequest is the request message for CreatePolicyRule.
type CreatePolicyRuleRequest struct {
Priority int32 `protobuf:"varint,1,opt,name=priority,proto3" json:"priority,omitempty"`
Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"`
Effect string `protobuf:"bytes,3,opt,name=effect,proto3" json:"effect,omitempty"`
Roles []string `protobuf:"bytes,4,rep,name=roles,proto3" json:"roles,omitempty"`
AccountTypes []string `protobuf:"bytes,5,rep,name=account_types,json=accountTypes,proto3" json:"account_types,omitempty"`
SubjectUuid string `protobuf:"bytes,6,opt,name=subject_uuid,json=subjectUuid,proto3" json:"subject_uuid,omitempty"` //nolint:revive,stylecheck // proto field name
Actions []string `protobuf:"bytes,7,rep,name=actions,proto3" json:"actions,omitempty"`
Repositories []string `protobuf:"bytes,8,rep,name=repositories,proto3" json:"repositories,omitempty"`
Enabled bool `protobuf:"varint,9,opt,name=enabled,proto3" json:"enabled,omitempty"`
}
func (x *CreatePolicyRuleRequest) GetPriority() int32 {
if x != nil {
return x.Priority
}
return 0
}
func (x *CreatePolicyRuleRequest) GetDescription() string {
if x != nil {
return x.Description
}
return ""
}
func (x *CreatePolicyRuleRequest) GetEffect() string {
if x != nil {
return x.Effect
}
return ""
}
func (x *CreatePolicyRuleRequest) GetRoles() []string {
if x != nil {
return x.Roles
}
return nil
}
func (x *CreatePolicyRuleRequest) GetAccountTypes() []string {
if x != nil {
return x.AccountTypes
}
return nil
}
func (x *CreatePolicyRuleRequest) GetSubjectUuid() string {
if x != nil {
return x.SubjectUuid
}
return ""
}
func (x *CreatePolicyRuleRequest) GetActions() []string {
if x != nil {
return x.Actions
}
return nil
}
func (x *CreatePolicyRuleRequest) GetRepositories() []string {
if x != nil {
return x.Repositories
}
return nil
}
func (x *CreatePolicyRuleRequest) GetEnabled() bool {
if x != nil {
return x.Enabled
}
return false
}
// GetPolicyRuleRequest is the request message for GetPolicyRule.
type GetPolicyRuleRequest struct {
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` //nolint:revive,stylecheck // proto field name
}
func (x *GetPolicyRuleRequest) GetId() int64 {
if x != nil {
return x.Id
}
return 0
}
// UpdatePolicyRuleRequest is the request message for UpdatePolicyRule.
type UpdatePolicyRuleRequest struct {
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` //nolint:revive,stylecheck // proto field name
Priority int32 `protobuf:"varint,2,opt,name=priority,proto3" json:"priority,omitempty"`
Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
Effect string `protobuf:"bytes,4,opt,name=effect,proto3" json:"effect,omitempty"`
Roles []string `protobuf:"bytes,5,rep,name=roles,proto3" json:"roles,omitempty"`
AccountTypes []string `protobuf:"bytes,6,rep,name=account_types,json=accountTypes,proto3" json:"account_types,omitempty"`
SubjectUuid string `protobuf:"bytes,7,opt,name=subject_uuid,json=subjectUuid,proto3" json:"subject_uuid,omitempty"` //nolint:revive,stylecheck // proto field name
Actions []string `protobuf:"bytes,8,rep,name=actions,proto3" json:"actions,omitempty"`
Repositories []string `protobuf:"bytes,9,rep,name=repositories,proto3" json:"repositories,omitempty"`
Enabled bool `protobuf:"varint,10,opt,name=enabled,proto3" json:"enabled,omitempty"`
UpdateMask []string `protobuf:"bytes,11,rep,name=update_mask,json=updateMask,proto3" json:"update_mask,omitempty"`
}
func (x *UpdatePolicyRuleRequest) GetId() int64 {
if x != nil {
return x.Id
}
return 0
}
func (x *UpdatePolicyRuleRequest) GetPriority() int32 {
if x != nil {
return x.Priority
}
return 0
}
func (x *UpdatePolicyRuleRequest) GetDescription() string {
if x != nil {
return x.Description
}
return ""
}
func (x *UpdatePolicyRuleRequest) GetEffect() string {
if x != nil {
return x.Effect
}
return ""
}
func (x *UpdatePolicyRuleRequest) GetRoles() []string {
if x != nil {
return x.Roles
}
return nil
}
func (x *UpdatePolicyRuleRequest) GetAccountTypes() []string {
if x != nil {
return x.AccountTypes
}
return nil
}
func (x *UpdatePolicyRuleRequest) GetSubjectUuid() string {
if x != nil {
return x.SubjectUuid
}
return ""
}
func (x *UpdatePolicyRuleRequest) GetActions() []string {
if x != nil {
return x.Actions
}
return nil
}
func (x *UpdatePolicyRuleRequest) GetRepositories() []string {
if x != nil {
return x.Repositories
}
return nil
}
func (x *UpdatePolicyRuleRequest) GetEnabled() bool {
if x != nil {
return x.Enabled
}
return false
}
func (x *UpdatePolicyRuleRequest) GetUpdateMask() []string {
if x != nil {
return x.UpdateMask
}
return nil
}
// DeletePolicyRuleRequest is the request message for DeletePolicyRule.
type DeletePolicyRuleRequest struct {
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` //nolint:revive,stylecheck // proto field name
}
func (x *DeletePolicyRuleRequest) GetId() int64 {
if x != nil {
return x.Id
}
return 0
}
// DeletePolicyRuleResponse is the response message for DeletePolicyRule.
type DeletePolicyRuleResponse struct{}

View File

@@ -0,0 +1,236 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// source: mcr/v1/policy.proto
package mcrv1
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// PolicyServiceServer is the server API for PolicyService.
type PolicyServiceServer interface {
ListPolicyRules(context.Context, *ListPolicyRulesRequest) (*ListPolicyRulesResponse, error)
CreatePolicyRule(context.Context, *CreatePolicyRuleRequest) (*PolicyRule, error)
GetPolicyRule(context.Context, *GetPolicyRuleRequest) (*PolicyRule, error)
UpdatePolicyRule(context.Context, *UpdatePolicyRuleRequest) (*PolicyRule, error)
DeletePolicyRule(context.Context, *DeletePolicyRuleRequest) (*DeletePolicyRuleResponse, error)
mustEmbedUnimplementedPolicyServiceServer()
}
// UnimplementedPolicyServiceServer should be embedded to have forward
// compatible implementations.
type UnimplementedPolicyServiceServer struct{}
func (UnimplementedPolicyServiceServer) ListPolicyRules(context.Context, *ListPolicyRulesRequest) (*ListPolicyRulesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListPolicyRules not implemented")
}
func (UnimplementedPolicyServiceServer) CreatePolicyRule(context.Context, *CreatePolicyRuleRequest) (*PolicyRule, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreatePolicyRule not implemented")
}
func (UnimplementedPolicyServiceServer) GetPolicyRule(context.Context, *GetPolicyRuleRequest) (*PolicyRule, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetPolicyRule not implemented")
}
func (UnimplementedPolicyServiceServer) UpdatePolicyRule(context.Context, *UpdatePolicyRuleRequest) (*PolicyRule, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdatePolicyRule not implemented")
}
func (UnimplementedPolicyServiceServer) DeletePolicyRule(context.Context, *DeletePolicyRuleRequest) (*DeletePolicyRuleResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeletePolicyRule not implemented")
}
func (UnimplementedPolicyServiceServer) mustEmbedUnimplementedPolicyServiceServer() {}
// RegisterPolicyServiceServer registers the PolicyServiceServer with the grpc.Server.
func RegisterPolicyServiceServer(s grpc.ServiceRegistrar, srv PolicyServiceServer) {
s.RegisterService(&PolicyService_ServiceDesc, srv)
}
func policyServiceListPolicyRulesHandler(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) {
in := new(ListPolicyRulesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(PolicyServiceServer).ListPolicyRules(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/mcr.v1.PolicyService/ListPolicyRules",
}
handler := func(ctx context.Context, req any) (any, error) {
return srv.(PolicyServiceServer).ListPolicyRules(ctx, req.(*ListPolicyRulesRequest))
}
return interceptor(ctx, in, info, handler)
}
func policyServiceCreatePolicyRuleHandler(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) {
in := new(CreatePolicyRuleRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(PolicyServiceServer).CreatePolicyRule(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/mcr.v1.PolicyService/CreatePolicyRule",
}
handler := func(ctx context.Context, req any) (any, error) {
return srv.(PolicyServiceServer).CreatePolicyRule(ctx, req.(*CreatePolicyRuleRequest))
}
return interceptor(ctx, in, info, handler)
}
func policyServiceGetPolicyRuleHandler(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) {
in := new(GetPolicyRuleRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(PolicyServiceServer).GetPolicyRule(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/mcr.v1.PolicyService/GetPolicyRule",
}
handler := func(ctx context.Context, req any) (any, error) {
return srv.(PolicyServiceServer).GetPolicyRule(ctx, req.(*GetPolicyRuleRequest))
}
return interceptor(ctx, in, info, handler)
}
func policyServiceUpdatePolicyRuleHandler(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) {
in := new(UpdatePolicyRuleRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(PolicyServiceServer).UpdatePolicyRule(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/mcr.v1.PolicyService/UpdatePolicyRule",
}
handler := func(ctx context.Context, req any) (any, error) {
return srv.(PolicyServiceServer).UpdatePolicyRule(ctx, req.(*UpdatePolicyRuleRequest))
}
return interceptor(ctx, in, info, handler)
}
func policyServiceDeletePolicyRuleHandler(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) {
in := new(DeletePolicyRuleRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(PolicyServiceServer).DeletePolicyRule(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/mcr.v1.PolicyService/DeletePolicyRule",
}
handler := func(ctx context.Context, req any) (any, error) {
return srv.(PolicyServiceServer).DeletePolicyRule(ctx, req.(*DeletePolicyRuleRequest))
}
return interceptor(ctx, in, info, handler)
}
// PolicyService_ServiceDesc is the grpc.ServiceDesc for PolicyService.
var PolicyService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mcr.v1.PolicyService",
HandlerType: (*PolicyServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListPolicyRules",
Handler: policyServiceListPolicyRulesHandler,
},
{
MethodName: "CreatePolicyRule",
Handler: policyServiceCreatePolicyRuleHandler,
},
{
MethodName: "GetPolicyRule",
Handler: policyServiceGetPolicyRuleHandler,
},
{
MethodName: "UpdatePolicyRule",
Handler: policyServiceUpdatePolicyRuleHandler,
},
{
MethodName: "DeletePolicyRule",
Handler: policyServiceDeletePolicyRuleHandler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "mcr/v1/policy.proto",
}
// PolicyServiceClient is the client API for PolicyService.
type PolicyServiceClient interface {
ListPolicyRules(ctx context.Context, in *ListPolicyRulesRequest, opts ...grpc.CallOption) (*ListPolicyRulesResponse, error)
CreatePolicyRule(ctx context.Context, in *CreatePolicyRuleRequest, opts ...grpc.CallOption) (*PolicyRule, error)
GetPolicyRule(ctx context.Context, in *GetPolicyRuleRequest, opts ...grpc.CallOption) (*PolicyRule, error)
UpdatePolicyRule(ctx context.Context, in *UpdatePolicyRuleRequest, opts ...grpc.CallOption) (*PolicyRule, error)
DeletePolicyRule(ctx context.Context, in *DeletePolicyRuleRequest, opts ...grpc.CallOption) (*DeletePolicyRuleResponse, error)
}
type policyServiceClient struct {
cc grpc.ClientConnInterface
}
// NewPolicyServiceClient creates a new PolicyServiceClient.
func NewPolicyServiceClient(cc grpc.ClientConnInterface) PolicyServiceClient {
return &policyServiceClient{cc}
}
func (c *policyServiceClient) ListPolicyRules(ctx context.Context, in *ListPolicyRulesRequest, opts ...grpc.CallOption) (*ListPolicyRulesResponse, error) {
out := new(ListPolicyRulesResponse)
err := c.cc.Invoke(ctx, "/mcr.v1.PolicyService/ListPolicyRules", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *policyServiceClient) CreatePolicyRule(ctx context.Context, in *CreatePolicyRuleRequest, opts ...grpc.CallOption) (*PolicyRule, error) {
out := new(PolicyRule)
err := c.cc.Invoke(ctx, "/mcr.v1.PolicyService/CreatePolicyRule", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *policyServiceClient) GetPolicyRule(ctx context.Context, in *GetPolicyRuleRequest, opts ...grpc.CallOption) (*PolicyRule, error) {
out := new(PolicyRule)
err := c.cc.Invoke(ctx, "/mcr.v1.PolicyService/GetPolicyRule", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *policyServiceClient) UpdatePolicyRule(ctx context.Context, in *UpdatePolicyRuleRequest, opts ...grpc.CallOption) (*PolicyRule, error) {
out := new(PolicyRule)
err := c.cc.Invoke(ctx, "/mcr.v1.PolicyService/UpdatePolicyRule", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *policyServiceClient) DeletePolicyRule(ctx context.Context, in *DeletePolicyRuleRequest, opts ...grpc.CallOption) (*DeletePolicyRuleResponse, error) {
out := new(DeletePolicyRuleResponse)
err := c.cc.Invoke(ctx, "/mcr.v1.PolicyService/DeletePolicyRule", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}

273
gen/mcr/v1/registry.pb.go Normal file
View File

@@ -0,0 +1,273 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// source: mcr/v1/registry.proto
package mcrv1
// RepositoryMetadata is a repository summary for listing.
type RepositoryMetadata struct {
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
TagCount int32 `protobuf:"varint,2,opt,name=tag_count,json=tagCount,proto3" json:"tag_count,omitempty"`
ManifestCount int32 `protobuf:"varint,3,opt,name=manifest_count,json=manifestCount,proto3" json:"manifest_count,omitempty"`
TotalSize int64 `protobuf:"varint,4,opt,name=total_size,json=totalSize,proto3" json:"total_size,omitempty"`
CreatedAt string `protobuf:"bytes,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
}
func (x *RepositoryMetadata) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *RepositoryMetadata) GetTagCount() int32 {
if x != nil {
return x.TagCount
}
return 0
}
func (x *RepositoryMetadata) GetManifestCount() int32 {
if x != nil {
return x.ManifestCount
}
return 0
}
func (x *RepositoryMetadata) GetTotalSize() int64 {
if x != nil {
return x.TotalSize
}
return 0
}
func (x *RepositoryMetadata) GetCreatedAt() string {
if x != nil {
return x.CreatedAt
}
return ""
}
// TagInfo is a tag with its manifest digest.
type TagInfo struct {
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Digest string `protobuf:"bytes,2,opt,name=digest,proto3" json:"digest,omitempty"`
}
func (x *TagInfo) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *TagInfo) GetDigest() string {
if x != nil {
return x.Digest
}
return ""
}
// ManifestInfo is a manifest summary.
type ManifestInfo struct {
Digest string `protobuf:"bytes,1,opt,name=digest,proto3" json:"digest,omitempty"`
MediaType string `protobuf:"bytes,2,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"`
Size int64 `protobuf:"varint,3,opt,name=size,proto3" json:"size,omitempty"`
CreatedAt string `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
}
func (x *ManifestInfo) GetDigest() string {
if x != nil {
return x.Digest
}
return ""
}
func (x *ManifestInfo) GetMediaType() string {
if x != nil {
return x.MediaType
}
return ""
}
func (x *ManifestInfo) GetSize() int64 {
if x != nil {
return x.Size
}
return 0
}
func (x *ManifestInfo) GetCreatedAt() string {
if x != nil {
return x.CreatedAt
}
return ""
}
// ListRepositoriesRequest is the request message for ListRepositories.
type ListRepositoriesRequest struct {
Pagination *PaginationRequest `protobuf:"bytes,1,opt,name=pagination,proto3" json:"pagination,omitempty"`
}
func (x *ListRepositoriesRequest) GetPagination() *PaginationRequest {
if x != nil {
return x.Pagination
}
return nil
}
// ListRepositoriesResponse is the response message for ListRepositories.
type ListRepositoriesResponse struct {
Repositories []*RepositoryMetadata `protobuf:"bytes,1,rep,name=repositories,proto3" json:"repositories,omitempty"`
}
func (x *ListRepositoriesResponse) GetRepositories() []*RepositoryMetadata {
if x != nil {
return x.Repositories
}
return nil
}
// GetRepositoryRequest is the request message for GetRepository.
type GetRepositoryRequest struct {
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
}
func (x *GetRepositoryRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
// GetRepositoryResponse is the response message for GetRepository.
type GetRepositoryResponse struct {
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Tags []*TagInfo `protobuf:"bytes,2,rep,name=tags,proto3" json:"tags,omitempty"`
Manifests []*ManifestInfo `protobuf:"bytes,3,rep,name=manifests,proto3" json:"manifests,omitempty"`
TotalSize int64 `protobuf:"varint,4,opt,name=total_size,json=totalSize,proto3" json:"total_size,omitempty"`
CreatedAt string `protobuf:"bytes,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
}
func (x *GetRepositoryResponse) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *GetRepositoryResponse) GetTags() []*TagInfo {
if x != nil {
return x.Tags
}
return nil
}
func (x *GetRepositoryResponse) GetManifests() []*ManifestInfo {
if x != nil {
return x.Manifests
}
return nil
}
func (x *GetRepositoryResponse) GetTotalSize() int64 {
if x != nil {
return x.TotalSize
}
return 0
}
func (x *GetRepositoryResponse) GetCreatedAt() string {
if x != nil {
return x.CreatedAt
}
return ""
}
// DeleteRepositoryRequest is the request message for DeleteRepository.
type DeleteRepositoryRequest struct {
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
}
func (x *DeleteRepositoryRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
// DeleteRepositoryResponse is the response message for DeleteRepository.
type DeleteRepositoryResponse struct{}
// GarbageCollectRequest is the request message for GarbageCollect.
type GarbageCollectRequest struct{}
// GarbageCollectResponse is the response message for GarbageCollect.
type GarbageCollectResponse struct {
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` //nolint:revive,stylecheck // proto field name
}
func (x *GarbageCollectResponse) GetId() string {
if x != nil {
return x.Id
}
return ""
}
// GetGCStatusRequest is the request message for GetGCStatus.
type GetGCStatusRequest struct{}
// GCLastRun records the result of the last garbage collection run.
type GCLastRun struct {
StartedAt string `protobuf:"bytes,1,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"`
CompletedAt string `protobuf:"bytes,2,opt,name=completed_at,json=completedAt,proto3" json:"completed_at,omitempty"`
BlobsRemoved int32 `protobuf:"varint,3,opt,name=blobs_removed,json=blobsRemoved,proto3" json:"blobs_removed,omitempty"`
BytesFreed int64 `protobuf:"varint,4,opt,name=bytes_freed,json=bytesFreed,proto3" json:"bytes_freed,omitempty"`
}
func (x *GCLastRun) GetStartedAt() string {
if x != nil {
return x.StartedAt
}
return ""
}
func (x *GCLastRun) GetCompletedAt() string {
if x != nil {
return x.CompletedAt
}
return ""
}
func (x *GCLastRun) GetBlobsRemoved() int32 {
if x != nil {
return x.BlobsRemoved
}
return 0
}
func (x *GCLastRun) GetBytesFreed() int64 {
if x != nil {
return x.BytesFreed
}
return 0
}
// GetGCStatusResponse is the response message for GetGCStatus.
type GetGCStatusResponse struct {
Running bool `protobuf:"varint,1,opt,name=running,proto3" json:"running,omitempty"`
LastRun *GCLastRun `protobuf:"bytes,2,opt,name=last_run,json=lastRun,proto3" json:"last_run,omitempty"`
}
func (x *GetGCStatusResponse) GetRunning() bool {
if x != nil {
return x.Running
}
return false
}
func (x *GetGCStatusResponse) GetLastRun() *GCLastRun {
if x != nil {
return x.LastRun
}
return nil
}

View File

@@ -0,0 +1,236 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// source: mcr/v1/registry.proto
package mcrv1
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// RegistryServiceServer is the server API for RegistryService.
type RegistryServiceServer interface {
ListRepositories(context.Context, *ListRepositoriesRequest) (*ListRepositoriesResponse, error)
GetRepository(context.Context, *GetRepositoryRequest) (*GetRepositoryResponse, error)
DeleteRepository(context.Context, *DeleteRepositoryRequest) (*DeleteRepositoryResponse, error)
GarbageCollect(context.Context, *GarbageCollectRequest) (*GarbageCollectResponse, error)
GetGCStatus(context.Context, *GetGCStatusRequest) (*GetGCStatusResponse, error)
mustEmbedUnimplementedRegistryServiceServer()
}
// UnimplementedRegistryServiceServer should be embedded to have forward
// compatible implementations.
type UnimplementedRegistryServiceServer struct{}
func (UnimplementedRegistryServiceServer) ListRepositories(context.Context, *ListRepositoriesRequest) (*ListRepositoriesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRepositories not implemented")
}
func (UnimplementedRegistryServiceServer) GetRepository(context.Context, *GetRepositoryRequest) (*GetRepositoryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRepository not implemented")
}
func (UnimplementedRegistryServiceServer) DeleteRepository(context.Context, *DeleteRepositoryRequest) (*DeleteRepositoryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteRepository not implemented")
}
func (UnimplementedRegistryServiceServer) GarbageCollect(context.Context, *GarbageCollectRequest) (*GarbageCollectResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GarbageCollect not implemented")
}
func (UnimplementedRegistryServiceServer) GetGCStatus(context.Context, *GetGCStatusRequest) (*GetGCStatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetGCStatus not implemented")
}
func (UnimplementedRegistryServiceServer) mustEmbedUnimplementedRegistryServiceServer() {}
// RegisterRegistryServiceServer registers the RegistryServiceServer with the grpc.Server.
func RegisterRegistryServiceServer(s grpc.ServiceRegistrar, srv RegistryServiceServer) {
s.RegisterService(&RegistryService_ServiceDesc, srv)
}
func registryServiceListRepositoriesHandler(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) {
in := new(ListRepositoriesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RegistryServiceServer).ListRepositories(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/mcr.v1.RegistryService/ListRepositories",
}
handler := func(ctx context.Context, req any) (any, error) {
return srv.(RegistryServiceServer).ListRepositories(ctx, req.(*ListRepositoriesRequest))
}
return interceptor(ctx, in, info, handler)
}
func registryServiceGetRepositoryHandler(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) {
in := new(GetRepositoryRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RegistryServiceServer).GetRepository(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/mcr.v1.RegistryService/GetRepository",
}
handler := func(ctx context.Context, req any) (any, error) {
return srv.(RegistryServiceServer).GetRepository(ctx, req.(*GetRepositoryRequest))
}
return interceptor(ctx, in, info, handler)
}
func registryServiceDeleteRepositoryHandler(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) {
in := new(DeleteRepositoryRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RegistryServiceServer).DeleteRepository(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/mcr.v1.RegistryService/DeleteRepository",
}
handler := func(ctx context.Context, req any) (any, error) {
return srv.(RegistryServiceServer).DeleteRepository(ctx, req.(*DeleteRepositoryRequest))
}
return interceptor(ctx, in, info, handler)
}
func registryServiceGarbageCollectHandler(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) {
in := new(GarbageCollectRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RegistryServiceServer).GarbageCollect(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/mcr.v1.RegistryService/GarbageCollect",
}
handler := func(ctx context.Context, req any) (any, error) {
return srv.(RegistryServiceServer).GarbageCollect(ctx, req.(*GarbageCollectRequest))
}
return interceptor(ctx, in, info, handler)
}
func registryServiceGetGCStatusHandler(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) {
in := new(GetGCStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RegistryServiceServer).GetGCStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/mcr.v1.RegistryService/GetGCStatus",
}
handler := func(ctx context.Context, req any) (any, error) {
return srv.(RegistryServiceServer).GetGCStatus(ctx, req.(*GetGCStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
// RegistryService_ServiceDesc is the grpc.ServiceDesc for RegistryService.
var RegistryService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mcr.v1.RegistryService",
HandlerType: (*RegistryServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListRepositories",
Handler: registryServiceListRepositoriesHandler,
},
{
MethodName: "GetRepository",
Handler: registryServiceGetRepositoryHandler,
},
{
MethodName: "DeleteRepository",
Handler: registryServiceDeleteRepositoryHandler,
},
{
MethodName: "GarbageCollect",
Handler: registryServiceGarbageCollectHandler,
},
{
MethodName: "GetGCStatus",
Handler: registryServiceGetGCStatusHandler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "mcr/v1/registry.proto",
}
// RegistryServiceClient is the client API for RegistryService.
type RegistryServiceClient interface {
ListRepositories(ctx context.Context, in *ListRepositoriesRequest, opts ...grpc.CallOption) (*ListRepositoriesResponse, error)
GetRepository(ctx context.Context, in *GetRepositoryRequest, opts ...grpc.CallOption) (*GetRepositoryResponse, error)
DeleteRepository(ctx context.Context, in *DeleteRepositoryRequest, opts ...grpc.CallOption) (*DeleteRepositoryResponse, error)
GarbageCollect(ctx context.Context, in *GarbageCollectRequest, opts ...grpc.CallOption) (*GarbageCollectResponse, error)
GetGCStatus(ctx context.Context, in *GetGCStatusRequest, opts ...grpc.CallOption) (*GetGCStatusResponse, error)
}
type registryServiceClient struct {
cc grpc.ClientConnInterface
}
// NewRegistryServiceClient creates a new RegistryServiceClient.
func NewRegistryServiceClient(cc grpc.ClientConnInterface) RegistryServiceClient {
return &registryServiceClient{cc}
}
func (c *registryServiceClient) ListRepositories(ctx context.Context, in *ListRepositoriesRequest, opts ...grpc.CallOption) (*ListRepositoriesResponse, error) {
out := new(ListRepositoriesResponse)
err := c.cc.Invoke(ctx, "/mcr.v1.RegistryService/ListRepositories", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *registryServiceClient) GetRepository(ctx context.Context, in *GetRepositoryRequest, opts ...grpc.CallOption) (*GetRepositoryResponse, error) {
out := new(GetRepositoryResponse)
err := c.cc.Invoke(ctx, "/mcr.v1.RegistryService/GetRepository", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *registryServiceClient) DeleteRepository(ctx context.Context, in *DeleteRepositoryRequest, opts ...grpc.CallOption) (*DeleteRepositoryResponse, error) {
out := new(DeleteRepositoryResponse)
err := c.cc.Invoke(ctx, "/mcr.v1.RegistryService/DeleteRepository", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *registryServiceClient) GarbageCollect(ctx context.Context, in *GarbageCollectRequest, opts ...grpc.CallOption) (*GarbageCollectResponse, error) {
out := new(GarbageCollectResponse)
err := c.cc.Invoke(ctx, "/mcr.v1.RegistryService/GarbageCollect", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *registryServiceClient) GetGCStatus(ctx context.Context, in *GetGCStatusRequest, opts ...grpc.CallOption) (*GetGCStatusResponse, error) {
out := new(GetGCStatusResponse)
err := c.cc.Invoke(ctx, "/mcr.v1.RegistryService/GetGCStatus", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}

5
go.mod
View File

@@ -13,7 +13,12 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

10
go.sum
View File

@@ -21,9 +21,19 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=

View File

@@ -0,0 +1,16 @@
package grpcserver
import (
"context"
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
)
// adminService implements pb.AdminServiceServer.
type adminService struct {
pb.UnimplementedAdminServiceServer
}
func (s *adminService) Health(_ context.Context, _ *pb.HealthRequest) (*pb.HealthResponse, error) {
return &pb.HealthResponse{Status: "ok"}, nil
}

View File

@@ -0,0 +1,43 @@
package grpcserver
import (
"context"
"testing"
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
"git.wntrmute.dev/kyle/mcr/internal/auth"
)
func TestHealthReturnsOk(t *testing.T) {
deps := adminDeps(t)
cc := startTestServer(t, deps)
client := pb.NewAdminServiceClient(cc)
resp, err := client.Health(context.Background(), &pb.HealthRequest{})
if err != nil {
t.Fatalf("Health: %v", err)
}
if resp.GetStatus() != "ok" {
t.Fatalf("status: got %q, want %q", resp.Status, "ok")
}
}
func TestHealthWithoutAuth(t *testing.T) {
database := openTestDB(t)
// Use a validator that always rejects.
validator := &fakeValidator{err: auth.ErrUnauthorized}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
})
client := pb.NewAdminServiceClient(cc)
resp, err := client.Health(context.Background(), &pb.HealthRequest{})
if err != nil {
t.Fatalf("Health without auth should succeed: %v", err)
}
if resp.GetStatus() != "ok" {
t.Fatalf("status: got %q, want %q", resp.Status, "ok")
}
}

View File

@@ -0,0 +1,61 @@
package grpcserver
import (
"context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
"git.wntrmute.dev/kyle/mcr/internal/db"
)
// auditService implements pb.AuditServiceServer.
type auditService struct {
pb.UnimplementedAuditServiceServer
db *db.DB
}
func (s *auditService) ListAuditEvents(_ context.Context, req *pb.ListAuditEventsRequest) (*pb.ListAuditEventsResponse, error) {
limit := int32(50)
offset := int32(0)
if req.GetPagination() != nil {
if req.Pagination.Limit > 0 {
limit = req.Pagination.Limit
}
if req.Pagination.Offset >= 0 {
offset = req.Pagination.Offset
}
}
filter := db.AuditFilter{
EventType: req.GetEventType(),
ActorID: req.GetActorId(),
Repository: req.GetRepository(),
Since: req.GetSince(),
Until: req.GetUntil(),
Limit: int(limit),
Offset: int(offset),
}
events, err := s.db.ListAuditEvents(filter)
if err != nil {
return nil, status.Errorf(codes.Internal, "internal error")
}
var result []*pb.AuditEvent
for _, e := range events {
result = append(result, &pb.AuditEvent{
Id: e.ID,
EventTime: e.EventTime,
EventType: e.EventType,
ActorId: e.ActorID,
Repository: e.Repository,
Digest: e.Digest,
IpAddress: e.IPAddress,
Details: e.Details,
})
}
return &pb.ListAuditEventsResponse{Events: result}, nil
}

View File

@@ -0,0 +1,95 @@
package grpcserver
import (
"testing"
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
)
func TestListAuditEventsEmpty(t *testing.T) {
deps := adminDeps(t)
cc := startTestServer(t, deps)
client := pb.NewAuditServiceClient(cc)
resp, err := client.ListAuditEvents(adminCtx(), &pb.ListAuditEventsRequest{})
if err != nil {
t.Fatalf("ListAuditEvents: %v", err)
}
if len(resp.GetEvents()) != 0 {
t.Fatalf("expected 0 events, got %d", len(resp.Events))
}
}
func TestListAuditEventsWithData(t *testing.T) {
deps := adminDeps(t)
// Write some audit events directly.
err := deps.DB.WriteAuditEvent("test_event", "actor-1", "repo/test", "", "1.2.3.4", map[string]string{"key": "value"})
if err != nil {
t.Fatalf("WriteAuditEvent: %v", err)
}
err = deps.DB.WriteAuditEvent("other_event", "actor-2", "", "", "5.6.7.8", nil)
if err != nil {
t.Fatalf("WriteAuditEvent: %v", err)
}
cc := startTestServer(t, deps)
client := pb.NewAuditServiceClient(cc)
// List all events.
resp, err := client.ListAuditEvents(adminCtx(), &pb.ListAuditEventsRequest{})
if err != nil {
t.Fatalf("ListAuditEvents: %v", err)
}
if len(resp.GetEvents()) != 2 {
t.Fatalf("expected 2 events, got %d", len(resp.Events))
}
// Filter by event type.
resp, err = client.ListAuditEvents(adminCtx(), &pb.ListAuditEventsRequest{
EventType: "test_event",
})
if err != nil {
t.Fatalf("ListAuditEvents with filter: %v", err)
}
if len(resp.GetEvents()) != 1 {
t.Fatalf("expected 1 event, got %d", len(resp.Events))
}
if resp.Events[0].EventType != "test_event" {
t.Fatalf("event_type: got %q, want %q", resp.Events[0].EventType, "test_event")
}
if resp.Events[0].ActorId != "actor-1" {
t.Fatalf("actor_id: got %q, want %q", resp.Events[0].ActorId, "actor-1")
}
if resp.Events[0].Details["key"] != "value" {
t.Fatalf("details: got %v, want key=value", resp.Events[0].Details)
}
}
func TestListAuditEventsPagination(t *testing.T) {
deps := adminDeps(t)
// Write 5 events.
for i := range 5 {
err := deps.DB.WriteAuditEvent("event", "actor", "", "", "", map[string]string{
"index": string(rune('0' + i)),
})
if err != nil {
t.Fatalf("WriteAuditEvent %d: %v", i, err)
}
}
cc := startTestServer(t, deps)
client := pb.NewAuditServiceClient(cc)
// Get first 2 events.
resp, err := client.ListAuditEvents(adminCtx(), &pb.ListAuditEventsRequest{
Pagination: &pb.PaginationRequest{Limit: 2},
})
if err != nil {
t.Fatalf("ListAuditEvents: %v", err)
}
if len(resp.GetEvents()) != 2 {
t.Fatalf("expected 2 events, got %d", len(resp.Events))
}
}

View File

@@ -0,0 +1,165 @@
package grpcserver
import (
"context"
"log"
"strings"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
"git.wntrmute.dev/kyle/mcr/internal/auth"
"git.wntrmute.dev/kyle/mcr/internal/server"
)
// authBypassMethods contains the full gRPC method names that bypass
// authentication. Health is the only method that does not require auth.
var authBypassMethods = map[string]bool{
"/mcr.v1.AdminService/Health": true,
}
// adminRequiredMethods contains the full gRPC method names that require
// the admin role. Adding an RPC without adding it to the correct map is
// a security defect per ARCHITECTURE.md.
var adminRequiredMethods = map[string]bool{
// Registry admin operations.
"/mcr.v1.RegistryService/DeleteRepository": true,
"/mcr.v1.RegistryService/GarbageCollect": true,
"/mcr.v1.RegistryService/GetGCStatus": true,
// Policy management — all RPCs require admin.
"/mcr.v1.PolicyService/ListPolicyRules": true,
"/mcr.v1.PolicyService/CreatePolicyRule": true,
"/mcr.v1.PolicyService/GetPolicyRule": true,
"/mcr.v1.PolicyService/UpdatePolicyRule": true,
"/mcr.v1.PolicyService/DeletePolicyRule": true,
// Audit — requires admin.
"/mcr.v1.AuditService/ListAuditEvents": true,
}
// authInterceptor validates bearer tokens from the authorization metadata.
type authInterceptor struct {
validator server.TokenValidator
}
func newAuthInterceptor(v server.TokenValidator) *authInterceptor {
return &authInterceptor{validator: v}
}
// unary is the unary server interceptor for auth.
func (a *authInterceptor) unary(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
// Health bypasses auth.
if authBypassMethods[info.FullMethod] {
return handler(ctx, req)
}
// Extract bearer token from authorization metadata.
token, err := extractToken(ctx)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "authentication required")
}
// Validate the token via MCIAS.
claims, err := a.validator.ValidateToken(token)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid token")
}
// Inject claims into the context.
ctx = auth.ContextWithClaims(ctx, claims)
return handler(ctx, req)
}
// extractToken extracts a bearer token from the "authorization" gRPC metadata.
func extractToken(ctx context.Context) (string, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return "", status.Errorf(codes.Unauthenticated, "missing metadata")
}
vals := md.Get("authorization")
if len(vals) == 0 {
return "", status.Errorf(codes.Unauthenticated, "missing authorization metadata")
}
val := vals[0]
const prefix = "Bearer "
if !strings.HasPrefix(val, prefix) {
return "", status.Errorf(codes.Unauthenticated, "invalid authorization format")
}
token := strings.TrimSpace(val[len(prefix):])
if token == "" {
return "", status.Errorf(codes.Unauthenticated, "empty bearer token")
}
return token, nil
}
// adminInterceptor checks that the caller has the admin role for
// methods in adminRequiredMethods.
type adminInterceptor struct{}
func newAdminInterceptor() *adminInterceptor {
return &adminInterceptor{}
}
// unary is the unary server interceptor for admin role checks.
func (a *adminInterceptor) unary(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
if !adminRequiredMethods[info.FullMethod] {
return handler(ctx, req)
}
claims := auth.ClaimsFromContext(ctx)
if claims == nil {
return nil, status.Errorf(codes.Unauthenticated, "authentication required")
}
if !hasRole(claims.Roles, "admin") {
return nil, status.Errorf(codes.PermissionDenied, "admin role required")
}
return handler(ctx, req)
}
// loggingInterceptor logs the method, peer IP, status code, and duration.
// It never logs the authorization metadata value.
func loggingInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
start := time.Now()
peerAddr := ""
if p, ok := peer.FromContext(ctx); ok {
peerAddr = p.Addr.String()
}
resp, err := handler(ctx, req)
duration := time.Since(start)
code := codes.OK
if err != nil {
if st, ok := status.FromError(err); ok {
code = st.Code()
} else {
code = codes.Unknown
}
}
log.Printf("grpc %s peer=%s code=%s duration=%s", info.FullMethod, peerAddr, code, duration)
return resp, err
}
// hasRole checks if any of the roles match the target role.
func hasRole(roles []string, target string) bool {
for _, r := range roles {
if r == target {
return true
}
}
return false
}

View File

@@ -0,0 +1,350 @@
package grpcserver
import (
"context"
"net"
"path/filepath"
"testing"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
"git.wntrmute.dev/kyle/mcr/internal/auth"
"git.wntrmute.dev/kyle/mcr/internal/db"
)
// fakeValidator is a test double for server.TokenValidator.
type fakeValidator struct {
claims *auth.Claims
err error
}
func (f *fakeValidator) ValidateToken(_ string) (*auth.Claims, error) {
return f.claims, f.err
}
// openTestDB creates a temporary test database with migrations applied.
func openTestDB(t *testing.T) *db.DB {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
d, err := db.Open(path)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = d.Close() })
if err := d.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
return d
}
// startTestServer creates a gRPC server and client for testing.
// Returns the client connection and a cleanup function.
func startTestServer(t *testing.T, deps Deps) *grpc.ClientConn {
t.Helper()
srv, err := New("", "", deps)
if err != nil {
t.Fatalf("New: %v", err)
}
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen: %v", err)
}
go func() {
_ = srv.Serve(lis)
}()
t.Cleanup(func() { srv.GracefulStop() })
//nolint:gosec // insecure credentials for testing only
cc, err := grpc.NewClient(
lis.Addr().String(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(grpc.ForceCodecV2(pb.JSONCodec{})),
)
if err != nil {
t.Fatalf("Dial: %v", err)
}
t.Cleanup(func() { _ = cc.Close() })
return cc
}
// withAuth adds a bearer token to the outgoing context metadata.
func withAuth(ctx context.Context, token string) context.Context {
return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token)
}
func TestHealthBypassesAuth(t *testing.T) {
database := openTestDB(t)
validator := &fakeValidator{err: auth.ErrUnauthorized}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
})
client := pb.NewAdminServiceClient(cc)
resp, err := client.Health(context.Background(), &pb.HealthRequest{})
if err != nil {
t.Fatalf("Health: %v", err)
}
if resp.Status != "ok" {
t.Fatalf("Health status: got %q, want %q", resp.Status, "ok")
}
}
func TestAuthInterceptorNoToken(t *testing.T) {
database := openTestDB(t)
validator := &fakeValidator{err: auth.ErrUnauthorized}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
})
client := pb.NewRegistryServiceClient(cc)
_, err := client.ListRepositories(context.Background(), &pb.ListRepositoriesRequest{})
if err == nil {
t.Fatal("expected error for unauthenticated request")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status error, got %v", err)
}
if st.Code() != codes.Unauthenticated {
t.Fatalf("code: got %v, want Unauthenticated", st.Code())
}
}
func TestAuthInterceptorInvalidToken(t *testing.T) {
database := openTestDB(t)
validator := &fakeValidator{err: auth.ErrUnauthorized}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
})
ctx := withAuth(context.Background(), "bad-token")
client := pb.NewRegistryServiceClient(cc)
_, err := client.ListRepositories(ctx, &pb.ListRepositoriesRequest{})
if err == nil {
t.Fatal("expected error for invalid token")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status error, got %v", err)
}
if st.Code() != codes.Unauthenticated {
t.Fatalf("code: got %v, want Unauthenticated", st.Code())
}
}
func TestAuthInterceptorValidToken(t *testing.T) {
database := openTestDB(t)
validator := &fakeValidator{
claims: &auth.Claims{Subject: "alice", AccountType: "human", Roles: []string{"user"}},
}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
})
ctx := withAuth(context.Background(), "valid-token")
client := pb.NewRegistryServiceClient(cc)
resp, err := client.ListRepositories(ctx, &pb.ListRepositoriesRequest{})
if err != nil {
t.Fatalf("ListRepositories: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
}
func TestAdminInterceptorDenied(t *testing.T) {
database := openTestDB(t)
validator := &fakeValidator{
claims: &auth.Claims{Subject: "user-uuid", AccountType: "human", Roles: []string{"user"}},
}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
})
ctx := withAuth(context.Background(), "valid-token")
// Policy RPCs require admin.
policyClient := pb.NewPolicyServiceClient(cc)
_, err := policyClient.ListPolicyRules(ctx, &pb.ListPolicyRulesRequest{})
if err == nil {
t.Fatal("expected error for non-admin user")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status error, got %v", err)
}
if st.Code() != codes.PermissionDenied {
t.Fatalf("code: got %v, want PermissionDenied", st.Code())
}
}
func TestAdminInterceptorAllowed(t *testing.T) {
database := openTestDB(t)
validator := &fakeValidator{
claims: &auth.Claims{Subject: "admin-uuid", AccountType: "human", Roles: []string{"admin"}},
}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
})
ctx := withAuth(context.Background(), "valid-token")
// Admin user should be able to list policy rules.
policyClient := pb.NewPolicyServiceClient(cc)
resp, err := policyClient.ListPolicyRules(ctx, &pb.ListPolicyRulesRequest{})
if err != nil {
t.Fatalf("ListPolicyRules: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
}
func TestAdminRequiredMethodsCompleteness(t *testing.T) {
// Verify that admin-required methods match our security spec.
// This test catches the security defect of adding an RPC without
// adding it to the adminRequiredMethods map.
expected := []string{
"/mcr.v1.RegistryService/DeleteRepository",
"/mcr.v1.RegistryService/GarbageCollect",
"/mcr.v1.RegistryService/GetGCStatus",
"/mcr.v1.PolicyService/ListPolicyRules",
"/mcr.v1.PolicyService/CreatePolicyRule",
"/mcr.v1.PolicyService/GetPolicyRule",
"/mcr.v1.PolicyService/UpdatePolicyRule",
"/mcr.v1.PolicyService/DeletePolicyRule",
"/mcr.v1.AuditService/ListAuditEvents",
}
for _, method := range expected {
if !adminRequiredMethods[method] {
t.Errorf("method %s should require admin but is not in adminRequiredMethods", method)
}
}
if len(adminRequiredMethods) != len(expected) {
t.Errorf("adminRequiredMethods has %d entries, expected %d", len(adminRequiredMethods), len(expected))
}
}
func TestAuthBypassMethodsCompleteness(t *testing.T) {
// Health is the only method that bypasses auth.
expected := []string{
"/mcr.v1.AdminService/Health",
}
for _, method := range expected {
if !authBypassMethods[method] {
t.Errorf("method %s should bypass auth but is not in authBypassMethods", method)
}
}
if len(authBypassMethods) != len(expected) {
t.Errorf("authBypassMethods has %d entries, expected %d", len(authBypassMethods), len(expected))
}
}
func TestDeleteRepoRequiresAdmin(t *testing.T) {
database := openTestDB(t)
validator := &fakeValidator{
claims: &auth.Claims{Subject: "user-uuid", AccountType: "human", Roles: []string{"user"}},
}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
})
ctx := withAuth(context.Background(), "valid-token")
client := pb.NewRegistryServiceClient(cc)
_, err := client.DeleteRepository(ctx, &pb.DeleteRepositoryRequest{Name: "test"})
if err == nil {
t.Fatal("expected error for non-admin user trying to delete repo")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status error, got %v", err)
}
if st.Code() != codes.PermissionDenied {
t.Fatalf("code: got %v, want PermissionDenied", st.Code())
}
}
func TestGCRequiresAdmin(t *testing.T) {
database := openTestDB(t)
validator := &fakeValidator{
claims: &auth.Claims{Subject: "user-uuid", AccountType: "human", Roles: []string{"user"}},
}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
})
ctx := withAuth(context.Background(), "valid-token")
client := pb.NewRegistryServiceClient(cc)
_, err := client.GarbageCollect(ctx, &pb.GarbageCollectRequest{})
if err == nil {
t.Fatal("expected error for non-admin user trying to trigger GC")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status error, got %v", err)
}
if st.Code() != codes.PermissionDenied {
t.Fatalf("code: got %v, want PermissionDenied", st.Code())
}
}
func TestAuditRequiresAdmin(t *testing.T) {
database := openTestDB(t)
validator := &fakeValidator{
claims: &auth.Claims{Subject: "user-uuid", AccountType: "human", Roles: []string{"user"}},
}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
})
ctx := withAuth(context.Background(), "valid-token")
client := pb.NewAuditServiceClient(cc)
_, err := client.ListAuditEvents(ctx, &pb.ListAuditEventsRequest{})
if err == nil {
t.Fatal("expected error for non-admin user trying to list audit events")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status error, got %v", err)
}
if st.Code() != codes.PermissionDenied {
t.Fatalf("code: got %v, want PermissionDenied", st.Code())
}
}

View File

@@ -0,0 +1,292 @@
package grpcserver
import (
"context"
"errors"
"fmt"
"strconv"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
"git.wntrmute.dev/kyle/mcr/internal/auth"
"git.wntrmute.dev/kyle/mcr/internal/db"
"git.wntrmute.dev/kyle/mcr/internal/policy"
)
var validActions = map[string]bool{
string(policy.ActionVersionCheck): true,
string(policy.ActionPull): true,
string(policy.ActionPush): true,
string(policy.ActionDelete): true,
string(policy.ActionCatalog): true,
string(policy.ActionPolicyManage): true,
}
// policyService implements pb.PolicyServiceServer.
type policyService struct {
pb.UnimplementedPolicyServiceServer
db *db.DB
engine PolicyReloader
auditFn AuditFunc
}
func (s *policyService) ListPolicyRules(_ context.Context, req *pb.ListPolicyRulesRequest) (*pb.ListPolicyRulesResponse, error) {
limit := int32(50)
offset := int32(0)
if req.GetPagination() != nil {
if req.Pagination.Limit > 0 {
limit = req.Pagination.Limit
}
if req.Pagination.Offset >= 0 {
offset = req.Pagination.Offset
}
}
rules, err := s.db.ListPolicyRules(int(limit), int(offset))
if err != nil {
return nil, status.Errorf(codes.Internal, "internal error")
}
var result []*pb.PolicyRule
for _, r := range rules {
result = append(result, policyRuleRowToProto(&r))
}
return &pb.ListPolicyRulesResponse{Rules: result}, nil
}
func (s *policyService) CreatePolicyRule(ctx context.Context, req *pb.CreatePolicyRuleRequest) (*pb.PolicyRule, error) {
if req.GetPriority() < 1 {
return nil, status.Errorf(codes.InvalidArgument, "priority must be >= 1 (0 is reserved for built-ins)")
}
if req.GetDescription() == "" {
return nil, status.Errorf(codes.InvalidArgument, "description is required")
}
if err := validateEffect(req.GetEffect()); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "%s", err.Error())
}
if len(req.GetActions()) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "at least one action is required")
}
if err := validateActions(req.GetActions()); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "%s", err.Error())
}
claims := auth.ClaimsFromContext(ctx)
createdBy := ""
if claims != nil {
createdBy = claims.Subject
}
row := db.PolicyRuleRow{
Priority: int(req.Priority),
Description: req.Description,
Effect: req.Effect,
Roles: req.Roles,
AccountTypes: req.AccountTypes,
SubjectUUID: req.SubjectUuid,
Actions: req.Actions,
Repositories: req.Repositories,
Enabled: req.Enabled,
CreatedBy: createdBy,
}
id, err := s.db.CreatePolicyRule(row)
if err != nil {
return nil, status.Errorf(codes.Internal, "internal error")
}
// Reload policy engine.
if s.engine != nil {
_ = s.engine.Reload(s.db)
}
if s.auditFn != nil {
s.auditFn("policy_rule_created", createdBy, "", "", "", map[string]string{
"rule_id": strconv.FormatInt(id, 10),
})
}
created, err := s.db.GetPolicyRule(id)
if err != nil {
return nil, status.Errorf(codes.Internal, "internal error")
}
return policyRuleRowToProto(created), nil
}
func (s *policyService) GetPolicyRule(_ context.Context, req *pb.GetPolicyRuleRequest) (*pb.PolicyRule, error) {
if req.GetId() == 0 {
return nil, status.Errorf(codes.InvalidArgument, "rule ID required")
}
rule, err := s.db.GetPolicyRule(req.Id)
if err != nil {
if errors.Is(err, db.ErrPolicyRuleNotFound) {
return nil, status.Errorf(codes.NotFound, "policy rule not found")
}
return nil, status.Errorf(codes.Internal, "internal error")
}
return policyRuleRowToProto(rule), nil
}
func (s *policyService) UpdatePolicyRule(ctx context.Context, req *pb.UpdatePolicyRuleRequest) (*pb.PolicyRule, error) {
if req.GetId() == 0 {
return nil, status.Errorf(codes.InvalidArgument, "rule ID required")
}
mask := make(map[string]bool, len(req.GetUpdateMask()))
for _, f := range req.GetUpdateMask() {
mask[f] = true
}
// Validate fields if they are in the update mask.
if mask["priority"] && req.Priority < 1 {
return nil, status.Errorf(codes.InvalidArgument, "priority must be >= 1 (0 is reserved for built-ins)")
}
if mask["effect"] {
if err := validateEffect(req.GetEffect()); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "%s", err.Error())
}
}
if mask["actions"] {
if len(req.GetActions()) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "at least one action is required")
}
if err := validateActions(req.GetActions()); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "%s", err.Error())
}
}
updates := db.PolicyRuleRow{}
if mask["priority"] {
updates.Priority = int(req.Priority)
}
if mask["description"] {
updates.Description = req.Description
}
if mask["effect"] {
updates.Effect = req.Effect
}
if mask["roles"] {
updates.Roles = req.Roles
}
if mask["account_types"] {
updates.AccountTypes = req.AccountTypes
}
if mask["subject_uuid"] {
updates.SubjectUUID = req.SubjectUuid
}
if mask["actions"] {
updates.Actions = req.Actions
}
if mask["repositories"] {
updates.Repositories = req.Repositories
}
if err := s.db.UpdatePolicyRule(req.Id, updates); err != nil {
if errors.Is(err, db.ErrPolicyRuleNotFound) {
return nil, status.Errorf(codes.NotFound, "policy rule not found")
}
return nil, status.Errorf(codes.Internal, "internal error")
}
// Handle enabled separately since it's a bool.
if mask["enabled"] {
if err := s.db.SetPolicyRuleEnabled(req.Id, req.Enabled); err != nil {
return nil, status.Errorf(codes.Internal, "internal error")
}
}
// Reload policy engine.
if s.engine != nil {
_ = s.engine.Reload(s.db)
}
if s.auditFn != nil {
claims := auth.ClaimsFromContext(ctx)
actorID := ""
if claims != nil {
actorID = claims.Subject
}
s.auditFn("policy_rule_updated", actorID, "", "", "", map[string]string{
"rule_id": strconv.FormatInt(req.Id, 10),
})
}
updated, err := s.db.GetPolicyRule(req.Id)
if err != nil {
return nil, status.Errorf(codes.Internal, "internal error")
}
return policyRuleRowToProto(updated), nil
}
func (s *policyService) DeletePolicyRule(ctx context.Context, req *pb.DeletePolicyRuleRequest) (*pb.DeletePolicyRuleResponse, error) {
if req.GetId() == 0 {
return nil, status.Errorf(codes.InvalidArgument, "rule ID required")
}
if err := s.db.DeletePolicyRule(req.Id); err != nil {
if errors.Is(err, db.ErrPolicyRuleNotFound) {
return nil, status.Errorf(codes.NotFound, "policy rule not found")
}
return nil, status.Errorf(codes.Internal, "internal error")
}
// Reload policy engine.
if s.engine != nil {
_ = s.engine.Reload(s.db)
}
if s.auditFn != nil {
claims := auth.ClaimsFromContext(ctx)
actorID := ""
if claims != nil {
actorID = claims.Subject
}
s.auditFn("policy_rule_deleted", actorID, "", "", "", map[string]string{
"rule_id": strconv.FormatInt(req.Id, 10),
})
}
return &pb.DeletePolicyRuleResponse{}, nil
}
// policyRuleRowToProto converts a db.PolicyRuleRow to a protobuf PolicyRule.
func policyRuleRowToProto(r *db.PolicyRuleRow) *pb.PolicyRule {
return &pb.PolicyRule{
Id: r.ID,
Priority: int32(r.Priority), //nolint:gosec // priority is always small positive int
Description: r.Description,
Effect: r.Effect,
Roles: r.Roles,
AccountTypes: r.AccountTypes,
SubjectUuid: r.SubjectUUID,
Actions: r.Actions,
Repositories: r.Repositories,
Enabled: r.Enabled,
CreatedBy: r.CreatedBy,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
func validateEffect(effect string) error {
if effect != "allow" && effect != "deny" {
return fmt.Errorf("invalid effect: %q (must be 'allow' or 'deny')", effect)
}
return nil
}
func validateActions(actions []string) error {
for _, a := range actions {
if !validActions[a] {
return fmt.Errorf("invalid action: %q", a)
}
}
return nil
}

View File

@@ -0,0 +1,306 @@
package grpcserver
import (
"testing"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
"git.wntrmute.dev/kyle/mcr/internal/policy"
)
type fakePolicyReloader struct {
reloadCount int
}
func (f *fakePolicyReloader) Reload(_ policy.RuleStore) error {
f.reloadCount++
return nil
}
func TestCreatePolicyRule(t *testing.T) {
deps := adminDeps(t)
reloader := &fakePolicyReloader{}
deps.Engine = reloader
cc := startTestServer(t, deps)
client := pb.NewPolicyServiceClient(cc)
resp, err := client.CreatePolicyRule(adminCtx(), &pb.CreatePolicyRuleRequest{
Priority: 10,
Description: "allow pull for all",
Effect: "allow",
Actions: []string{"registry:pull"},
Enabled: true,
})
if err != nil {
t.Fatalf("CreatePolicyRule: %v", err)
}
if resp.GetId() == 0 {
t.Fatal("expected non-zero ID")
}
if resp.GetDescription() != "allow pull for all" {
t.Fatalf("description: got %q, want %q", resp.Description, "allow pull for all")
}
if resp.GetEffect() != "allow" {
t.Fatalf("effect: got %q, want %q", resp.Effect, "allow")
}
if !resp.GetEnabled() {
t.Fatal("expected enabled=true")
}
if reloader.reloadCount != 1 {
t.Fatalf("reloadCount: got %d, want 1", reloader.reloadCount)
}
}
func TestCreatePolicyRuleValidation(t *testing.T) {
deps := adminDeps(t)
cc := startTestServer(t, deps)
client := pb.NewPolicyServiceClient(cc)
tests := []struct {
name string
req *pb.CreatePolicyRuleRequest
code codes.Code
}{
{
name: "zero priority",
req: &pb.CreatePolicyRuleRequest{
Priority: 0,
Description: "test",
Effect: "allow",
Actions: []string{"registry:pull"},
},
code: codes.InvalidArgument,
},
{
name: "empty description",
req: &pb.CreatePolicyRuleRequest{
Priority: 1,
Effect: "allow",
Actions: []string{"registry:pull"},
},
code: codes.InvalidArgument,
},
{
name: "invalid effect",
req: &pb.CreatePolicyRuleRequest{
Priority: 1,
Description: "test",
Effect: "maybe",
Actions: []string{"registry:pull"},
},
code: codes.InvalidArgument,
},
{
name: "no actions",
req: &pb.CreatePolicyRuleRequest{
Priority: 1,
Description: "test",
Effect: "allow",
},
code: codes.InvalidArgument,
},
{
name: "invalid action",
req: &pb.CreatePolicyRuleRequest{
Priority: 1,
Description: "test",
Effect: "allow",
Actions: []string{"registry:fly"},
},
code: codes.InvalidArgument,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := client.CreatePolicyRule(adminCtx(), tt.req)
if err == nil {
t.Fatal("expected error")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status, got %v", err)
}
if st.Code() != tt.code {
t.Fatalf("code: got %v, want %v", st.Code(), tt.code)
}
})
}
}
func TestGetPolicyRule(t *testing.T) {
deps := adminDeps(t)
cc := startTestServer(t, deps)
client := pb.NewPolicyServiceClient(cc)
// Create a rule first.
created, err := client.CreatePolicyRule(adminCtx(), &pb.CreatePolicyRuleRequest{
Priority: 5,
Description: "test rule",
Effect: "deny",
Actions: []string{"registry:push"},
Enabled: true,
})
if err != nil {
t.Fatalf("CreatePolicyRule: %v", err)
}
// Fetch it.
got, err := client.GetPolicyRule(adminCtx(), &pb.GetPolicyRuleRequest{Id: created.Id})
if err != nil {
t.Fatalf("GetPolicyRule: %v", err)
}
if got.Id != created.Id {
t.Fatalf("id: got %d, want %d", got.Id, created.Id)
}
if got.Effect != "deny" {
t.Fatalf("effect: got %q, want %q", got.Effect, "deny")
}
}
func TestGetPolicyRuleNotFound(t *testing.T) {
deps := adminDeps(t)
cc := startTestServer(t, deps)
client := pb.NewPolicyServiceClient(cc)
_, err := client.GetPolicyRule(adminCtx(), &pb.GetPolicyRuleRequest{Id: 99999})
if err == nil {
t.Fatal("expected error")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status, got %v", err)
}
if st.Code() != codes.NotFound {
t.Fatalf("code: got %v, want NotFound", st.Code())
}
}
func TestListPolicyRules(t *testing.T) {
deps := adminDeps(t)
cc := startTestServer(t, deps)
client := pb.NewPolicyServiceClient(cc)
// Create two rules.
for i := range 2 {
_, err := client.CreatePolicyRule(adminCtx(), &pb.CreatePolicyRuleRequest{
Priority: int32(i + 1),
Description: "rule",
Effect: "allow",
Actions: []string{"registry:pull"},
Enabled: true,
})
if err != nil {
t.Fatalf("CreatePolicyRule %d: %v", i, err)
}
}
resp, err := client.ListPolicyRules(adminCtx(), &pb.ListPolicyRulesRequest{})
if err != nil {
t.Fatalf("ListPolicyRules: %v", err)
}
if len(resp.GetRules()) < 2 {
t.Fatalf("expected at least 2 rules, got %d", len(resp.Rules))
}
}
func TestDeletePolicyRule(t *testing.T) {
deps := adminDeps(t)
reloader := &fakePolicyReloader{}
deps.Engine = reloader
cc := startTestServer(t, deps)
client := pb.NewPolicyServiceClient(cc)
// Create then delete.
created, err := client.CreatePolicyRule(adminCtx(), &pb.CreatePolicyRuleRequest{
Priority: 1,
Description: "to be deleted",
Effect: "allow",
Actions: []string{"registry:pull"},
Enabled: true,
})
if err != nil {
t.Fatalf("CreatePolicyRule: %v", err)
}
initialReloads := reloader.reloadCount
_, err = client.DeletePolicyRule(adminCtx(), &pb.DeletePolicyRuleRequest{Id: created.Id})
if err != nil {
t.Fatalf("DeletePolicyRule: %v", err)
}
// Verify it was reloaded.
if reloader.reloadCount != initialReloads+1 {
t.Fatalf("reloadCount: got %d, want %d", reloader.reloadCount, initialReloads+1)
}
// Verify it's gone.
_, err = client.GetPolicyRule(adminCtx(), &pb.GetPolicyRuleRequest{Id: created.Id})
if err == nil {
t.Fatal("expected error after deletion")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status, got %v", err)
}
if st.Code() != codes.NotFound {
t.Fatalf("code: got %v, want NotFound", st.Code())
}
}
func TestDeletePolicyRuleNotFound(t *testing.T) {
deps := adminDeps(t)
cc := startTestServer(t, deps)
client := pb.NewPolicyServiceClient(cc)
_, err := client.DeletePolicyRule(adminCtx(), &pb.DeletePolicyRuleRequest{Id: 99999})
if err == nil {
t.Fatal("expected error")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status, got %v", err)
}
if st.Code() != codes.NotFound {
t.Fatalf("code: got %v, want NotFound", st.Code())
}
}
func TestUpdatePolicyRule(t *testing.T) {
deps := adminDeps(t)
reloader := &fakePolicyReloader{}
deps.Engine = reloader
cc := startTestServer(t, deps)
client := pb.NewPolicyServiceClient(cc)
// Create a rule.
created, err := client.CreatePolicyRule(adminCtx(), &pb.CreatePolicyRuleRequest{
Priority: 10,
Description: "original",
Effect: "allow",
Actions: []string{"registry:pull"},
Enabled: true,
})
if err != nil {
t.Fatalf("CreatePolicyRule: %v", err)
}
// Update description.
updated, err := client.UpdatePolicyRule(adminCtx(), &pb.UpdatePolicyRuleRequest{
Id: created.Id,
Description: "updated description",
UpdateMask: []string{"description"},
})
if err != nil {
t.Fatalf("UpdatePolicyRule: %v", err)
}
if updated.Description != "updated description" {
t.Fatalf("description: got %q, want %q", updated.Description, "updated description")
}
// Effect should be unchanged.
if updated.Effect != "allow" {
t.Fatalf("effect: got %q, want %q", updated.Effect, "allow")
}
}

View File

@@ -0,0 +1,203 @@
package grpcserver
import (
"context"
"errors"
"fmt"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/google/uuid"
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
"git.wntrmute.dev/kyle/mcr/internal/auth"
"git.wntrmute.dev/kyle/mcr/internal/db"
"git.wntrmute.dev/kyle/mcr/internal/gc"
)
// registryService implements pb.RegistryServiceServer.
type registryService struct {
pb.UnimplementedRegistryServiceServer
db *db.DB
collector *gc.Collector
gcStatus *GCStatus
auditFn AuditFunc
}
func (s *registryService) ListRepositories(_ context.Context, req *pb.ListRepositoriesRequest) (*pb.ListRepositoriesResponse, error) {
limit := int32(50)
offset := int32(0)
if req.GetPagination() != nil {
if req.Pagination.Limit > 0 {
limit = req.Pagination.Limit
}
if req.Pagination.Offset >= 0 {
offset = req.Pagination.Offset
}
}
repos, err := s.db.ListRepositoriesWithMetadata(int(limit), int(offset))
if err != nil {
return nil, status.Errorf(codes.Internal, "internal error")
}
var result []*pb.RepositoryMetadata
for _, r := range repos {
result = append(result, &pb.RepositoryMetadata{
Name: r.Name,
TagCount: int32(r.TagCount), //nolint:gosec // tag count fits int32
ManifestCount: int32(r.ManifestCount), //nolint:gosec // manifest count fits int32
TotalSize: r.TotalSize,
CreatedAt: r.CreatedAt,
})
}
return &pb.ListRepositoriesResponse{Repositories: result}, nil
}
func (s *registryService) GetRepository(_ context.Context, req *pb.GetRepositoryRequest) (*pb.GetRepositoryResponse, error) {
if req.GetName() == "" {
return nil, status.Errorf(codes.InvalidArgument, "repository name required")
}
detail, err := s.db.GetRepositoryDetail(req.Name)
if err != nil {
if errors.Is(err, db.ErrRepoNotFound) {
return nil, status.Errorf(codes.NotFound, "repository not found")
}
return nil, status.Errorf(codes.Internal, "internal error")
}
resp := &pb.GetRepositoryResponse{
Name: detail.Name,
TotalSize: detail.TotalSize,
CreatedAt: detail.CreatedAt,
}
for _, t := range detail.Tags {
resp.Tags = append(resp.Tags, &pb.TagInfo{
Name: t.Name,
Digest: t.Digest,
})
}
for _, m := range detail.Manifests {
resp.Manifests = append(resp.Manifests, &pb.ManifestInfo{
Digest: m.Digest,
MediaType: m.MediaType,
Size: m.Size,
CreatedAt: m.CreatedAt,
})
}
return resp, nil
}
func (s *registryService) DeleteRepository(ctx context.Context, req *pb.DeleteRepositoryRequest) (*pb.DeleteRepositoryResponse, error) {
if req.GetName() == "" {
return nil, status.Errorf(codes.InvalidArgument, "repository name required")
}
if err := s.db.DeleteRepository(req.Name); err != nil {
if errors.Is(err, db.ErrRepoNotFound) {
return nil, status.Errorf(codes.NotFound, "repository not found")
}
return nil, status.Errorf(codes.Internal, "internal error")
}
if s.auditFn != nil {
claims := auth.ClaimsFromContext(ctx)
actorID := ""
if claims != nil {
actorID = claims.Subject
}
s.auditFn("repo_deleted", actorID, req.Name, "", "", nil)
}
return &pb.DeleteRepositoryResponse{}, nil
}
func (s *registryService) GarbageCollect(_ context.Context, _ *pb.GarbageCollectRequest) (*pb.GarbageCollectResponse, error) {
s.gcStatus.mu.Lock()
if s.gcStatus.running {
s.gcStatus.mu.Unlock()
return nil, status.Errorf(codes.AlreadyExists, "garbage collection already running")
}
s.gcStatus.running = true
s.gcStatus.mu.Unlock()
gcID := uuid.New().String()
// Run GC asynchronously like the REST handler. GC is a long-running
// background operation that must not be tied to the request context,
// so we intentionally use context.Background() inside runGC.
go s.runGC(gcID) //nolint:gosec // G118: GC must outlive the triggering RPC
return &pb.GarbageCollectResponse{Id: gcID}, nil
}
// runGC executes garbage collection in the background. It uses
// context.Background() because GC must not be cancelled when the
// triggering RPC completes.
func (s *registryService) runGC(gcID string) {
startedAt := time.Now().UTC().Format(time.RFC3339)
if s.auditFn != nil {
s.auditFn("gc_started", "", "", "", "", map[string]string{
"gc_id": gcID,
})
}
var blobsRemoved int
var bytesFreed int64
var gcErr error
if s.collector != nil {
r, err := s.collector.Run(context.Background()) //nolint:gosec // GC is intentionally background, not request-scoped
if err != nil {
gcErr = err
}
if r != nil {
blobsRemoved = r.BlobsRemoved
bytesFreed = r.BytesFreed
}
}
completedAt := time.Now().UTC().Format(time.RFC3339)
s.gcStatus.mu.Lock()
s.gcStatus.running = false
s.gcStatus.lastRun = &gcLastRun{
StartedAt: startedAt,
CompletedAt: completedAt,
BlobsRemoved: blobsRemoved,
BytesFreed: bytesFreed,
}
s.gcStatus.mu.Unlock()
if s.auditFn != nil && gcErr == nil {
details := map[string]string{
"gc_id": gcID,
"blobs_removed": fmt.Sprintf("%d", blobsRemoved),
"bytes_freed": fmt.Sprintf("%d", bytesFreed),
}
s.auditFn("gc_completed", "", "", "", "", details)
}
}
func (s *registryService) GetGCStatus(_ context.Context, _ *pb.GetGCStatusRequest) (*pb.GetGCStatusResponse, error) {
s.gcStatus.mu.Lock()
resp := &pb.GetGCStatusResponse{
Running: s.gcStatus.running,
}
if s.gcStatus.lastRun != nil {
resp.LastRun = &pb.GCLastRun{
StartedAt: s.gcStatus.lastRun.StartedAt,
CompletedAt: s.gcStatus.lastRun.CompletedAt,
BlobsRemoved: int32(s.gcStatus.lastRun.BlobsRemoved), //nolint:gosec // blob count fits int32
BytesFreed: s.gcStatus.lastRun.BytesFreed,
}
}
s.gcStatus.mu.Unlock()
return resp, nil
}

View File

@@ -0,0 +1,144 @@
package grpcserver
import (
"context"
"testing"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
"git.wntrmute.dev/kyle/mcr/internal/auth"
)
func adminDeps(t *testing.T) Deps {
t.Helper()
return Deps{
DB: openTestDB(t),
Validator: &fakeValidator{
claims: &auth.Claims{Subject: "admin-uuid", AccountType: "human", Roles: []string{"admin"}},
},
}
}
func adminCtx() context.Context {
return withAuth(context.Background(), "admin-token")
}
func TestListRepositoriesEmpty(t *testing.T) {
deps := adminDeps(t)
cc := startTestServer(t, deps)
client := pb.NewRegistryServiceClient(cc)
resp, err := client.ListRepositories(adminCtx(), &pb.ListRepositoriesRequest{})
if err != nil {
t.Fatalf("ListRepositories: %v", err)
}
if len(resp.GetRepositories()) != 0 {
t.Fatalf("expected 0 repos, got %d", len(resp.Repositories))
}
}
func TestGetRepositoryNotFound(t *testing.T) {
deps := adminDeps(t)
cc := startTestServer(t, deps)
client := pb.NewRegistryServiceClient(cc)
_, err := client.GetRepository(adminCtx(), &pb.GetRepositoryRequest{Name: "nonexistent"})
if err == nil {
t.Fatal("expected error for nonexistent repo")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status, got %v", err)
}
if st.Code() != codes.NotFound {
t.Fatalf("code: got %v, want NotFound", st.Code())
}
}
func TestGetRepositoryEmptyName(t *testing.T) {
deps := adminDeps(t)
cc := startTestServer(t, deps)
client := pb.NewRegistryServiceClient(cc)
_, err := client.GetRepository(adminCtx(), &pb.GetRepositoryRequest{})
if err == nil {
t.Fatal("expected error for empty name")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status, got %v", err)
}
if st.Code() != codes.InvalidArgument {
t.Fatalf("code: got %v, want InvalidArgument", st.Code())
}
}
func TestDeleteRepositoryNotFound(t *testing.T) {
deps := adminDeps(t)
cc := startTestServer(t, deps)
client := pb.NewRegistryServiceClient(cc)
_, err := client.DeleteRepository(adminCtx(), &pb.DeleteRepositoryRequest{Name: "nonexistent"})
if err == nil {
t.Fatal("expected error for nonexistent repo")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status, got %v", err)
}
if st.Code() != codes.NotFound {
t.Fatalf("code: got %v, want NotFound", st.Code())
}
}
func TestDeleteRepositoryEmptyName(t *testing.T) {
deps := adminDeps(t)
cc := startTestServer(t, deps)
client := pb.NewRegistryServiceClient(cc)
_, err := client.DeleteRepository(adminCtx(), &pb.DeleteRepositoryRequest{})
if err == nil {
t.Fatal("expected error for empty name")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status, got %v", err)
}
if st.Code() != codes.InvalidArgument {
t.Fatalf("code: got %v, want InvalidArgument", st.Code())
}
}
func TestGCStatusInitial(t *testing.T) {
deps := adminDeps(t)
cc := startTestServer(t, deps)
client := pb.NewRegistryServiceClient(cc)
resp, err := client.GetGCStatus(adminCtx(), &pb.GetGCStatusRequest{})
if err != nil {
t.Fatalf("GetGCStatus: %v", err)
}
if resp.Running {
t.Fatal("expected running=false on startup")
}
if resp.LastRun != nil {
t.Fatal("expected no last_run on startup")
}
}
func TestGarbageCollectTrigger(t *testing.T) {
deps := adminDeps(t)
cc := startTestServer(t, deps)
client := pb.NewRegistryServiceClient(cc)
// Trigger GC without a collector (no-op but should return an ID).
resp, err := client.GarbageCollect(adminCtx(), &pb.GarbageCollectRequest{})
if err != nil {
t.Fatalf("GarbageCollect: %v", err)
}
if resp.GetId() == "" {
t.Fatal("expected non-empty GC ID")
}
}

View File

@@ -0,0 +1,141 @@
// Package grpcserver implements the MCR gRPC admin API server.
//
// It provides the same business logic as the REST admin API in
// internal/server/, using shared internal/db and internal/gc packages.
// The server enforces TLS 1.3 minimum, auth via MCIAS token validation,
// and admin role checks on privileged RPCs.
package grpcserver
import (
"crypto/tls"
"fmt"
"log"
"net"
"sync"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
"git.wntrmute.dev/kyle/mcr/internal/db"
"git.wntrmute.dev/kyle/mcr/internal/gc"
"git.wntrmute.dev/kyle/mcr/internal/policy"
"git.wntrmute.dev/kyle/mcr/internal/server"
)
// AuditFunc is a callback for recording audit events. It follows the same
// signature as db.WriteAuditEvent but without an error return -- audit
// failures should not block request processing.
type AuditFunc func(eventType, actorID, repository, digest, ip string, details map[string]string)
// Deps holds the dependencies injected into the gRPC server.
type Deps struct {
DB *db.DB
Validator server.TokenValidator
Engine PolicyReloader
AuditFn AuditFunc
Collector *gc.Collector
}
// PolicyReloader can reload policy rules from a store.
type PolicyReloader interface {
Reload(store policy.RuleStore) error
}
// GCStatus tracks the current state of garbage collection for the gRPC server.
type GCStatus struct {
mu sync.Mutex
running bool
lastRun *gcLastRun
}
type gcLastRun struct {
StartedAt string
CompletedAt string
BlobsRemoved int
BytesFreed int64
}
// Server wraps a grpc.Server with MCR-specific configuration.
type Server struct {
gs *grpc.Server
deps Deps
gcStatus *GCStatus
}
// New creates a configured gRPC server with the interceptor chain:
// [Request Logger] -> [Auth Interceptor] -> [Admin Interceptor] -> [Handler]
//
// The TLS config enforces TLS 1.3 minimum. If certFile or keyFile is
// empty, the server is created without TLS (for testing only).
func New(certFile, keyFile string, deps Deps) (*Server, error) {
authInt := newAuthInterceptor(deps.Validator)
adminInt := newAdminInterceptor()
chain := grpc.ChainUnaryInterceptor(
loggingInterceptor,
authInt.unary,
adminInt.unary,
)
var opts []grpc.ServerOption
opts = append(opts, chain)
// Configure TLS if cert and key are provided.
if certFile != "" && keyFile != "" {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("grpcserver: load TLS cert: %w", err)
}
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS13,
}
opts = append(opts, grpc.Creds(credentials.NewTLS(tlsCfg)))
}
// The JSON codec is registered globally via init() in gen/mcr/v1/codec.go.
// The client must use grpc.ForceCodecV2(mcrv1.JSONCodec{}) to match.
_ = pb.JSONCodec{} // ensure the gen/mcr/v1 init() runs (codec registration)
gs := grpc.NewServer(opts...)
gcStatus := &GCStatus{}
s := &Server{gs: gs, deps: deps, gcStatus: gcStatus}
// Register all services.
pb.RegisterRegistryServiceServer(gs, &registryService{
db: deps.DB,
collector: deps.Collector,
gcStatus: gcStatus,
auditFn: deps.AuditFn,
})
pb.RegisterPolicyServiceServer(gs, &policyService{
db: deps.DB,
engine: deps.Engine,
auditFn: deps.AuditFn,
})
pb.RegisterAuditServiceServer(gs, &auditService{
db: deps.DB,
})
pb.RegisterAdminServiceServer(gs, &adminService{})
return s, nil
}
// Serve starts the gRPC server on the given listener.
func (s *Server) Serve(lis net.Listener) error {
log.Printf("grpc server listening on %s", lis.Addr())
return s.gs.Serve(lis)
}
// GracefulStop gracefully stops the gRPC server.
func (s *Server) GracefulStop() {
s.gs.GracefulStop()
}
// GRPCServer returns the underlying grpc.Server for testing.
func (s *Server) GRPCServer() *grpc.Server {
return s.gs
}

15
proto/mcr/v1/admin.proto Normal file
View File

@@ -0,0 +1,15 @@
syntax = "proto3";
package mcr.v1;
option go_package = "git.wntrmute.dev/kyle/mcr/gen/mcr/v1;mcrv1";
service AdminService {
rpc Health(HealthRequest) returns (HealthResponse);
}
message HealthRequest {}
message HealthResponse {
string status = 1;
}

35
proto/mcr/v1/audit.proto Normal file
View File

@@ -0,0 +1,35 @@
syntax = "proto3";
package mcr.v1;
option go_package = "git.wntrmute.dev/kyle/mcr/gen/mcr/v1;mcrv1";
import "mcr/v1/common.proto";
service AuditService {
rpc ListAuditEvents(ListAuditEventsRequest) returns (ListAuditEventsResponse);
}
message AuditEvent {
int64 id = 1;
string event_time = 2;
string event_type = 3;
string actor_id = 4;
string repository = 5;
string digest = 6;
string ip_address = 7;
map<string, string> details = 8;
}
message ListAuditEventsRequest {
PaginationRequest pagination = 1;
string event_type = 2;
string actor_id = 3;
string repository = 4;
string since = 5;
string until = 6;
}
message ListAuditEventsResponse {
repeated AuditEvent events = 1;
}

11
proto/mcr/v1/common.proto Normal file
View File

@@ -0,0 +1,11 @@
syntax = "proto3";
package mcr.v1;
option go_package = "git.wntrmute.dev/kyle/mcr/gen/mcr/v1;mcrv1";
// Pagination controls for list RPCs.
message PaginationRequest {
int32 limit = 1;
int32 offset = 2;
}

76
proto/mcr/v1/policy.proto Normal file
View File

@@ -0,0 +1,76 @@
syntax = "proto3";
package mcr.v1;
option go_package = "git.wntrmute.dev/kyle/mcr/gen/mcr/v1;mcrv1";
import "mcr/v1/common.proto";
service PolicyService {
rpc ListPolicyRules(ListPolicyRulesRequest) returns (ListPolicyRulesResponse);
rpc CreatePolicyRule(CreatePolicyRuleRequest) returns (PolicyRule);
rpc GetPolicyRule(GetPolicyRuleRequest) returns (PolicyRule);
rpc UpdatePolicyRule(UpdatePolicyRuleRequest) returns (PolicyRule);
rpc DeletePolicyRule(DeletePolicyRuleRequest) returns (DeletePolicyRuleResponse);
}
message PolicyRule {
int64 id = 1;
int32 priority = 2;
string description = 3;
string effect = 4;
repeated string roles = 5;
repeated string account_types = 6;
string subject_uuid = 7;
repeated string actions = 8;
repeated string repositories = 9;
bool enabled = 10;
string created_by = 11;
string created_at = 12;
string updated_at = 13;
}
message ListPolicyRulesRequest {
PaginationRequest pagination = 1;
}
message ListPolicyRulesResponse {
repeated PolicyRule rules = 1;
}
message CreatePolicyRuleRequest {
int32 priority = 1;
string description = 2;
string effect = 3;
repeated string roles = 4;
repeated string account_types = 5;
string subject_uuid = 6;
repeated string actions = 7;
repeated string repositories = 8;
bool enabled = 9;
}
message GetPolicyRuleRequest {
int64 id = 1;
}
message UpdatePolicyRuleRequest {
int64 id = 1;
int32 priority = 2;
string description = 3;
string effect = 4;
repeated string roles = 5;
repeated string account_types = 6;
string subject_uuid = 7;
repeated string actions = 8;
repeated string repositories = 9;
bool enabled = 10;
// Field mask for partial updates — only fields listed here are applied.
repeated string update_mask = 11;
}
message DeletePolicyRuleRequest {
int64 id = 1;
}
message DeletePolicyRuleResponse {}

View File

@@ -0,0 +1,81 @@
syntax = "proto3";
package mcr.v1;
option go_package = "git.wntrmute.dev/kyle/mcr/gen/mcr/v1;mcrv1";
import "mcr/v1/common.proto";
service RegistryService {
rpc ListRepositories(ListRepositoriesRequest) returns (ListRepositoriesResponse);
rpc GetRepository(GetRepositoryRequest) returns (GetRepositoryResponse);
rpc DeleteRepository(DeleteRepositoryRequest) returns (DeleteRepositoryResponse);
rpc GarbageCollect(GarbageCollectRequest) returns (GarbageCollectResponse);
rpc GetGCStatus(GetGCStatusRequest) returns (GetGCStatusResponse);
}
message RepositoryMetadata {
string name = 1;
int32 tag_count = 2;
int32 manifest_count = 3;
int64 total_size = 4;
string created_at = 5;
}
message TagInfo {
string name = 1;
string digest = 2;
}
message ManifestInfo {
string digest = 1;
string media_type = 2;
int64 size = 3;
string created_at = 4;
}
message ListRepositoriesRequest {
PaginationRequest pagination = 1;
}
message ListRepositoriesResponse {
repeated RepositoryMetadata repositories = 1;
}
message GetRepositoryRequest {
string name = 1;
}
message GetRepositoryResponse {
string name = 1;
repeated TagInfo tags = 2;
repeated ManifestInfo manifests = 3;
int64 total_size = 4;
string created_at = 5;
}
message DeleteRepositoryRequest {
string name = 1;
}
message DeleteRepositoryResponse {}
message GarbageCollectRequest {}
message GarbageCollectResponse {
string id = 1;
}
message GetGCStatusRequest {}
message GCLastRun {
string started_at = 1;
string completed_at = 2;
int32 blobs_removed = 3;
int64 bytes_freed = 4;
}
message GetGCStatusResponse {
bool running = 1;
GCLastRun last_run = 2;
}