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:
94
PROGRESS.md
94
PROGRESS.md
@@ -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
|
||||
|
||||
@@ -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
19
gen/mcr/v1/admin.pb.go
Normal 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 ""
|
||||
}
|
||||
88
gen/mcr/v1/admin_grpc.pb.go
Normal file
88
gen/mcr/v1/admin_grpc.pb.go
Normal 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
136
gen/mcr/v1/audit.pb.go
Normal 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
|
||||
}
|
||||
88
gen/mcr/v1/audit_grpc.pb.go
Normal file
88
gen/mcr/v1/audit_grpc.pb.go
Normal 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
44
gen/mcr/v1/codec.go
Normal 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
24
gen/mcr/v1/common.pb.go
Normal 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
331
gen/mcr/v1/policy.pb.go
Normal 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{}
|
||||
236
gen/mcr/v1/policy_grpc.pb.go
Normal file
236
gen/mcr/v1/policy_grpc.pb.go
Normal 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
273
gen/mcr/v1/registry.pb.go
Normal 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
|
||||
}
|
||||
236
gen/mcr/v1/registry_grpc.pb.go
Normal file
236
gen/mcr/v1/registry_grpc.pb.go
Normal 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 ®istryServiceClient{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
5
go.mod
@@ -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
10
go.sum
@@ -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=
|
||||
|
||||
16
internal/grpcserver/admin.go
Normal file
16
internal/grpcserver/admin.go
Normal 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
|
||||
}
|
||||
43
internal/grpcserver/admin_test.go
Normal file
43
internal/grpcserver/admin_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
61
internal/grpcserver/audit.go
Normal file
61
internal/grpcserver/audit.go
Normal 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
|
||||
}
|
||||
95
internal/grpcserver/audit_test.go
Normal file
95
internal/grpcserver/audit_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
165
internal/grpcserver/interceptors.go
Normal file
165
internal/grpcserver/interceptors.go
Normal 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
|
||||
}
|
||||
350
internal/grpcserver/interceptors_test.go
Normal file
350
internal/grpcserver/interceptors_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
292
internal/grpcserver/policy.go
Normal file
292
internal/grpcserver/policy.go
Normal 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
|
||||
}
|
||||
306
internal/grpcserver/policy_test.go
Normal file
306
internal/grpcserver/policy_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
203
internal/grpcserver/registry.go
Normal file
203
internal/grpcserver/registry.go
Normal 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
|
||||
}
|
||||
144
internal/grpcserver/registry_test.go
Normal file
144
internal/grpcserver/registry_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
141
internal/grpcserver/server.go
Normal file
141
internal/grpcserver/server.go
Normal 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, ®istryService{
|
||||
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
15
proto/mcr/v1/admin.proto
Normal 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
35
proto/mcr/v1/audit.proto
Normal 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
11
proto/mcr/v1/common.proto
Normal 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
76
proto/mcr/v1/policy.proto
Normal 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 {}
|
||||
81
proto/mcr/v1/registry.proto
Normal file
81
proto/mcr/v1/registry.proto
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user