diff --git a/design/EP-1270-scoped-authorization.md b/design/EP-1270-scoped-authorization.md index f9fef279a..4f7ff2726 100644 --- a/design/EP-1270-scoped-authorization.md +++ b/design/EP-1270-scoped-authorization.md @@ -29,14 +29,15 @@ Authorization decisions use trusted resource identity. Unauthorized resources ar - Define roles, policies, claims, subjects, grants, or catalog keys. - Protect `SandboxAgent`, `AgentHarness`, `AgentInstance`, `ModelProviderConfig`, tool server, or prompt template resources. - Expose policy-engine, SQL, Kubernetes, or other backend expressions. -- Predict authorization for UI controls. +- Embed authorization capability hints in catalog resources. ## Authorization model -Kagent needs two forms of authorization decision: +Kagent needs three forms of authorization decision: - Whether a principal may perform an operation on a specific resource. - Which resources a principal may receive from a collection request. +- Whether an advisory client action is currently permitted for an exact resource or for any valid resource name in a namespace. A collection decision may allow the complete collection, deny the complete collection, or describe allowed alternatives. Each alternative may constrain both namespace and name. Alternatives are combined with OR, while constraints within an alternative are combined with AND. Each constraint may allow one or more exact values. @@ -60,9 +61,20 @@ A protected collection returns only resources permitted by its collection decisi A decision that permits no resources returns an empty collection. An authorization failure or a decision that cannot be safely applied fails the request; it never broadens access. +## Advisory access review + +An access review with a resource name uses the same exact authorization check as +the corresponding operation. A target without a name is an existential question: +whether the complete authorization scope contains at least one valid resource name +in that namespace. It is not an exact check with an empty or wildcard name. + +An authorizer that cannot produce a complete scope fails the namespace-only +review rather than returning partial results. Reviews are advisory; every resource +operation authorizes its actual input again. + ## Client behavior -Catalog responses do not include create, update, or delete capability hints for presentation logic. Such hints duplicate policy decisions, can become stale, and couple the public API to a particular client experience. +Catalog responses do not include create, update, or delete capability hints. Such hints duplicate policy decisions, can become stale, and couple the resource API to a particular client experience. Clients may request separate advisory access reviews when they need early permission signals. A client may therefore display an action that the caller cannot complete. The attempted operation remains authoritative and returns permission denied. Clients should handle that response without treating it as an unexpected server failure. @@ -77,4 +89,4 @@ Resources outside the initial scope retain their existing authorization behavior - Checking items after pagination was rejected because it can produce incomplete pages and incorrect totals. - Separate allowed-name and allowed-namespace lists were rejected because they cannot preserve required relationships between attributes. - Backend query fragments were rejected because they couple authorization policy to storage and create an unsafe trust boundary. -- UI capability hints were rejected because the operation itself is the only authoritative authorization decision. +- Capability hints were rejected because the operation itself is the only authoritative authorization decision. diff --git a/go/api/gen/kagent/api/v1alpha1/authorization.pb.go b/go/api/gen/kagent/api/v1alpha1/authorization.pb.go new file mode 100644 index 000000000..3dbbba638 --- /dev/null +++ b/go/api/gen/kagent/api/v1alpha1/authorization.pb.go @@ -0,0 +1,439 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: kagent/api/v1alpha1/authorization.proto + +package apiv1alpha1 + +import ( + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type AuthorizationResourceType int32 + +const ( + AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_UNSPECIFIED AuthorizationResourceType = 0 + AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE AuthorizationResourceType = 1 + AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_HARNESS AuthorizationResourceType = 2 + AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG AuthorizationResourceType = 3 +) + +// Enum value maps for AuthorizationResourceType. +var ( + AuthorizationResourceType_name = map[int32]string{ + 0: "AUTHORIZATION_RESOURCE_TYPE_UNSPECIFIED", + 1: "AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE", + 2: "AUTHORIZATION_RESOURCE_TYPE_HARNESS", + 3: "AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG", + } + AuthorizationResourceType_value = map[string]int32{ + "AUTHORIZATION_RESOURCE_TYPE_UNSPECIFIED": 0, + "AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE": 1, + "AUTHORIZATION_RESOURCE_TYPE_HARNESS": 2, + "AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG": 3, + } +) + +func (x AuthorizationResourceType) Enum() *AuthorizationResourceType { + p := new(AuthorizationResourceType) + *p = x + return p +} + +func (x AuthorizationResourceType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AuthorizationResourceType) Descriptor() protoreflect.EnumDescriptor { + return file_kagent_api_v1alpha1_authorization_proto_enumTypes[0].Descriptor() +} + +func (AuthorizationResourceType) Type() protoreflect.EnumType { + return &file_kagent_api_v1alpha1_authorization_proto_enumTypes[0] +} + +func (x AuthorizationResourceType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AuthorizationResourceType.Descriptor instead. +func (AuthorizationResourceType) EnumDescriptor() ([]byte, []int) { + return file_kagent_api_v1alpha1_authorization_proto_rawDescGZIP(), []int{0} +} + +type AuthorizationVerb int32 + +const ( + AuthorizationVerb_AUTHORIZATION_VERB_UNSPECIFIED AuthorizationVerb = 0 + AuthorizationVerb_AUTHORIZATION_VERB_GET AuthorizationVerb = 1 + AuthorizationVerb_AUTHORIZATION_VERB_CREATE AuthorizationVerb = 2 + AuthorizationVerb_AUTHORIZATION_VERB_UPDATE AuthorizationVerb = 3 + AuthorizationVerb_AUTHORIZATION_VERB_DELETE AuthorizationVerb = 4 +) + +// Enum value maps for AuthorizationVerb. +var ( + AuthorizationVerb_name = map[int32]string{ + 0: "AUTHORIZATION_VERB_UNSPECIFIED", + 1: "AUTHORIZATION_VERB_GET", + 2: "AUTHORIZATION_VERB_CREATE", + 3: "AUTHORIZATION_VERB_UPDATE", + 4: "AUTHORIZATION_VERB_DELETE", + } + AuthorizationVerb_value = map[string]int32{ + "AUTHORIZATION_VERB_UNSPECIFIED": 0, + "AUTHORIZATION_VERB_GET": 1, + "AUTHORIZATION_VERB_CREATE": 2, + "AUTHORIZATION_VERB_UPDATE": 3, + "AUTHORIZATION_VERB_DELETE": 4, + } +) + +func (x AuthorizationVerb) Enum() *AuthorizationVerb { + p := new(AuthorizationVerb) + *p = x + return p +} + +func (x AuthorizationVerb) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AuthorizationVerb) Descriptor() protoreflect.EnumDescriptor { + return file_kagent_api_v1alpha1_authorization_proto_enumTypes[1].Descriptor() +} + +func (AuthorizationVerb) Type() protoreflect.EnumType { + return &file_kagent_api_v1alpha1_authorization_proto_enumTypes[1] +} + +func (x AuthorizationVerb) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AuthorizationVerb.Descriptor instead. +func (AuthorizationVerb) EnumDescriptor() ([]byte, []int) { + return file_kagent_api_v1alpha1_authorization_proto_rawDescGZIP(), []int{1} +} + +type CheckAccessRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ResourceType AuthorizationResourceType `protobuf:"varint,1,opt,name=resource_type,json=resourceType,proto3,enum=kagent.api.v1alpha1.AuthorizationResourceType" json:"resource_type,omitempty"` + Verbs []AuthorizationVerb `protobuf:"varint,2,rep,packed,name=verbs,proto3,enum=kagent.api.v1alpha1.AuthorizationVerb" json:"verbs,omitempty"` + Targets []*AccessTarget `protobuf:"bytes,3,rep,name=targets,proto3" json:"targets,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckAccessRequest) Reset() { + *x = CheckAccessRequest{} + mi := &file_kagent_api_v1alpha1_authorization_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckAccessRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckAccessRequest) ProtoMessage() {} + +func (x *CheckAccessRequest) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1alpha1_authorization_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CheckAccessRequest.ProtoReflect.Descriptor instead. +func (*CheckAccessRequest) Descriptor() ([]byte, []int) { + return file_kagent_api_v1alpha1_authorization_proto_rawDescGZIP(), []int{0} +} + +func (x *CheckAccessRequest) GetResourceType() AuthorizationResourceType { + if x != nil { + return x.ResourceType + } + return AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_UNSPECIFIED +} + +func (x *CheckAccessRequest) GetVerbs() []AuthorizationVerb { + if x != nil { + return x.Verbs + } + return nil +} + +func (x *CheckAccessRequest) GetTargets() []*AccessTarget { + if x != nil { + return x.Targets + } + return nil +} + +type AccessTarget struct { + state protoimpl.MessageState `protogen:"open.v1"` + Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` + // When present, the review checks this exact resource identity. When omitted, + // it checks whether any valid resource name in the namespace is permitted. + Name *string `protobuf:"bytes,2,opt,name=name,proto3,oneof" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AccessTarget) Reset() { + *x = AccessTarget{} + mi := &file_kagent_api_v1alpha1_authorization_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AccessTarget) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AccessTarget) ProtoMessage() {} + +func (x *AccessTarget) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1alpha1_authorization_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AccessTarget.ProtoReflect.Descriptor instead. +func (*AccessTarget) Descriptor() ([]byte, []int) { + return file_kagent_api_v1alpha1_authorization_proto_rawDescGZIP(), []int{1} +} + +func (x *AccessTarget) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *AccessTarget) GetName() string { + if x != nil && x.Name != nil { + return *x.Name + } + return "" +} + +type CheckAccessResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Results []*ResourceAccess `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckAccessResponse) Reset() { + *x = CheckAccessResponse{} + mi := &file_kagent_api_v1alpha1_authorization_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckAccessResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckAccessResponse) ProtoMessage() {} + +func (x *CheckAccessResponse) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1alpha1_authorization_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CheckAccessResponse.ProtoReflect.Descriptor instead. +func (*CheckAccessResponse) Descriptor() ([]byte, []int) { + return file_kagent_api_v1alpha1_authorization_proto_rawDescGZIP(), []int{2} +} + +func (x *CheckAccessResponse) GetResults() []*ResourceAccess { + if x != nil { + return x.Results + } + return nil +} + +type ResourceAccess struct { + state protoimpl.MessageState `protogen:"open.v1"` + Target *AccessTarget `protobuf:"bytes,1,opt,name=target,proto3" json:"target,omitempty"` + AllowedVerbs []AuthorizationVerb `protobuf:"varint,2,rep,packed,name=allowed_verbs,json=allowedVerbs,proto3,enum=kagent.api.v1alpha1.AuthorizationVerb" json:"allowed_verbs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResourceAccess) Reset() { + *x = ResourceAccess{} + mi := &file_kagent_api_v1alpha1_authorization_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceAccess) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceAccess) ProtoMessage() {} + +func (x *ResourceAccess) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1alpha1_authorization_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceAccess.ProtoReflect.Descriptor instead. +func (*ResourceAccess) Descriptor() ([]byte, []int) { + return file_kagent_api_v1alpha1_authorization_proto_rawDescGZIP(), []int{3} +} + +func (x *ResourceAccess) GetTarget() *AccessTarget { + if x != nil { + return x.Target + } + return nil +} + +func (x *ResourceAccess) GetAllowedVerbs() []AuthorizationVerb { + if x != nil { + return x.AllowedVerbs + } + return nil +} + +var File_kagent_api_v1alpha1_authorization_proto protoreflect.FileDescriptor + +const file_kagent_api_v1alpha1_authorization_proto_rawDesc = "" + + "\n" + + "'kagent/api/v1alpha1/authorization.proto\x12\x13kagent.api.v1alpha1\x1a\x1bbuf/validate/validate.proto\"\xb8\x03\n" + + "\x12CheckAccessRequest\x12_\n" + + "\rresource_type\x18\x01 \x01(\x0e2..kagent.api.v1alpha1.AuthorizationResourceTypeB\n" + + "\xbaH\a\x82\x01\x04\x10\x01 \x00R\fresourceType\x12S\n" + + "\x05verbs\x18\x02 \x03(\x0e2&.kagent.api.v1alpha1.AuthorizationVerbB\x15\xbaH\x12\x92\x01\x0f\b\x01\x10\x04\x18\x01\"\a\x82\x01\x04\x10\x01 \x00R\x05verbs\x12G\n" + + "\atargets\x18\x03 \x03(\v2!.kagent.api.v1alpha1.AccessTargetB\n" + + "\xbaH\a\x92\x01\x04\b\x01\x10dR\atargets:\xa2\x01\xbaH\x9e\x01\x1a\x9b\x01\n" + + "\x18supported_resource_verbs\x126Harness supports only CREATE and DELETE access reviews\x1aGthis.resource_type != 2 || this.verbs.all(verb, verb == 2 || verb == 4)\"\xca\x01\n" + + "\fAccessTarget\x12H\n" + + "\tnamespace\x18\x01 \x01(\tB*\xbaH'r%\x10\x01\x18?2\x1f^[a-z0-9]([-a-z0-9]*[a-z0-9])?$R\tnamespace\x12g\n" + + "\x04name\x18\x02 \x01(\tBN\xbaHKrI\x10\x01\x18\xfd\x012B^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$H\x00R\x04name\x88\x01\x01B\a\n" + + "\x05_name\"T\n" + + "\x13CheckAccessResponse\x12=\n" + + "\aresults\x18\x01 \x03(\v2#.kagent.api.v1alpha1.ResourceAccessR\aresults\"\x98\x01\n" + + "\x0eResourceAccess\x129\n" + + "\x06target\x18\x01 \x01(\v2!.kagent.api.v1alpha1.AccessTargetR\x06target\x12K\n" + + "\rallowed_verbs\x18\x02 \x03(\x0e2&.kagent.api.v1alpha1.AuthorizationVerbR\fallowedVerbs*\xcf\x01\n" + + "\x19AuthorizationResourceType\x12+\n" + + "'AUTHORIZATION_RESOURCE_TYPE_UNSPECIFIED\x10\x00\x12.\n" + + "*AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE\x10\x01\x12'\n" + + "#AUTHORIZATION_RESOURCE_TYPE_HARNESS\x10\x02\x12,\n" + + "(AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG\x10\x03*\xb0\x01\n" + + "\x11AuthorizationVerb\x12\"\n" + + "\x1eAUTHORIZATION_VERB_UNSPECIFIED\x10\x00\x12\x1a\n" + + "\x16AUTHORIZATION_VERB_GET\x10\x01\x12\x1d\n" + + "\x19AUTHORIZATION_VERB_CREATE\x10\x02\x12\x1d\n" + + "\x19AUTHORIZATION_VERB_UPDATE\x10\x03\x12\x1d\n" + + "\x19AUTHORIZATION_VERB_DELETE\x10\x042x\n" + + "\x14AuthorizationService\x12`\n" + + "\vCheckAccess\x12'.kagent.api.v1alpha1.CheckAccessRequest\x1a(.kagent.api.v1alpha1.CheckAccessResponseBIZGgithub.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1;apiv1alpha1b\x06proto3" + +var ( + file_kagent_api_v1alpha1_authorization_proto_rawDescOnce sync.Once + file_kagent_api_v1alpha1_authorization_proto_rawDescData []byte +) + +func file_kagent_api_v1alpha1_authorization_proto_rawDescGZIP() []byte { + file_kagent_api_v1alpha1_authorization_proto_rawDescOnce.Do(func() { + file_kagent_api_v1alpha1_authorization_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_kagent_api_v1alpha1_authorization_proto_rawDesc), len(file_kagent_api_v1alpha1_authorization_proto_rawDesc))) + }) + return file_kagent_api_v1alpha1_authorization_proto_rawDescData +} + +var file_kagent_api_v1alpha1_authorization_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_kagent_api_v1alpha1_authorization_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_kagent_api_v1alpha1_authorization_proto_goTypes = []any{ + (AuthorizationResourceType)(0), // 0: kagent.api.v1alpha1.AuthorizationResourceType + (AuthorizationVerb)(0), // 1: kagent.api.v1alpha1.AuthorizationVerb + (*CheckAccessRequest)(nil), // 2: kagent.api.v1alpha1.CheckAccessRequest + (*AccessTarget)(nil), // 3: kagent.api.v1alpha1.AccessTarget + (*CheckAccessResponse)(nil), // 4: kagent.api.v1alpha1.CheckAccessResponse + (*ResourceAccess)(nil), // 5: kagent.api.v1alpha1.ResourceAccess +} +var file_kagent_api_v1alpha1_authorization_proto_depIdxs = []int32{ + 0, // 0: kagent.api.v1alpha1.CheckAccessRequest.resource_type:type_name -> kagent.api.v1alpha1.AuthorizationResourceType + 1, // 1: kagent.api.v1alpha1.CheckAccessRequest.verbs:type_name -> kagent.api.v1alpha1.AuthorizationVerb + 3, // 2: kagent.api.v1alpha1.CheckAccessRequest.targets:type_name -> kagent.api.v1alpha1.AccessTarget + 5, // 3: kagent.api.v1alpha1.CheckAccessResponse.results:type_name -> kagent.api.v1alpha1.ResourceAccess + 3, // 4: kagent.api.v1alpha1.ResourceAccess.target:type_name -> kagent.api.v1alpha1.AccessTarget + 1, // 5: kagent.api.v1alpha1.ResourceAccess.allowed_verbs:type_name -> kagent.api.v1alpha1.AuthorizationVerb + 2, // 6: kagent.api.v1alpha1.AuthorizationService.CheckAccess:input_type -> kagent.api.v1alpha1.CheckAccessRequest + 4, // 7: kagent.api.v1alpha1.AuthorizationService.CheckAccess:output_type -> kagent.api.v1alpha1.CheckAccessResponse + 7, // [7:8] is the sub-list for method output_type + 6, // [6:7] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_kagent_api_v1alpha1_authorization_proto_init() } +func file_kagent_api_v1alpha1_authorization_proto_init() { + if File_kagent_api_v1alpha1_authorization_proto != nil { + return + } + file_kagent_api_v1alpha1_authorization_proto_msgTypes[1].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_kagent_api_v1alpha1_authorization_proto_rawDesc), len(file_kagent_api_v1alpha1_authorization_proto_rawDesc)), + NumEnums: 2, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_kagent_api_v1alpha1_authorization_proto_goTypes, + DependencyIndexes: file_kagent_api_v1alpha1_authorization_proto_depIdxs, + EnumInfos: file_kagent_api_v1alpha1_authorization_proto_enumTypes, + MessageInfos: file_kagent_api_v1alpha1_authorization_proto_msgTypes, + }.Build() + File_kagent_api_v1alpha1_authorization_proto = out.File + file_kagent_api_v1alpha1_authorization_proto_goTypes = nil + file_kagent_api_v1alpha1_authorization_proto_depIdxs = nil +} diff --git a/go/api/gen/kagent/api/v1alpha1/authorization_grpc.pb.go b/go/api/gen/kagent/api/v1alpha1/authorization_grpc.pb.go new file mode 100644 index 000000000..d432aa6ef --- /dev/null +++ b/go/api/gen/kagent/api/v1alpha1/authorization_grpc.pb.go @@ -0,0 +1,127 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc (unknown) +// source: kagent/api/v1alpha1/authorization.proto + +package apiv1alpha1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + AuthorizationService_CheckAccess_FullMethodName = "/kagent.api.v1alpha1.AuthorizationService/CheckAccess" +) + +// AuthorizationServiceClient is the client API for AuthorizationService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// AuthorizationService answers advisory access checks for catalog operations. +// Catalog operations remain authoritative. +type AuthorizationServiceClient interface { + CheckAccess(ctx context.Context, in *CheckAccessRequest, opts ...grpc.CallOption) (*CheckAccessResponse, error) +} + +type authorizationServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewAuthorizationServiceClient(cc grpc.ClientConnInterface) AuthorizationServiceClient { + return &authorizationServiceClient{cc} +} + +func (c *authorizationServiceClient) CheckAccess(ctx context.Context, in *CheckAccessRequest, opts ...grpc.CallOption) (*CheckAccessResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CheckAccessResponse) + err := c.cc.Invoke(ctx, AuthorizationService_CheckAccess_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// AuthorizationServiceServer is the server API for AuthorizationService service. +// All implementations must embed UnimplementedAuthorizationServiceServer +// for forward compatibility. +// +// AuthorizationService answers advisory access checks for catalog operations. +// Catalog operations remain authoritative. +type AuthorizationServiceServer interface { + CheckAccess(context.Context, *CheckAccessRequest) (*CheckAccessResponse, error) + mustEmbedUnimplementedAuthorizationServiceServer() +} + +// UnimplementedAuthorizationServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedAuthorizationServiceServer struct{} + +func (UnimplementedAuthorizationServiceServer) CheckAccess(context.Context, *CheckAccessRequest) (*CheckAccessResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CheckAccess not implemented") +} +func (UnimplementedAuthorizationServiceServer) mustEmbedUnimplementedAuthorizationServiceServer() {} +func (UnimplementedAuthorizationServiceServer) testEmbeddedByValue() {} + +// UnsafeAuthorizationServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to AuthorizationServiceServer will +// result in compilation errors. +type UnsafeAuthorizationServiceServer interface { + mustEmbedUnimplementedAuthorizationServiceServer() +} + +func RegisterAuthorizationServiceServer(s grpc.ServiceRegistrar, srv AuthorizationServiceServer) { + // If the following call panics, it indicates UnimplementedAuthorizationServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&AuthorizationService_ServiceDesc, srv) +} + +func _AuthorizationService_CheckAccess_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CheckAccessRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorizationServiceServer).CheckAccess(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorizationService_CheckAccess_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorizationServiceServer).CheckAccess(ctx, req.(*CheckAccessRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// AuthorizationService_ServiceDesc is the grpc.ServiceDesc for AuthorizationService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var AuthorizationService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "kagent.api.v1alpha1.AuthorizationService", + HandlerType: (*AuthorizationServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "CheckAccess", + Handler: _AuthorizationService_CheckAccess_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "kagent/api/v1alpha1/authorization.proto", +} diff --git a/go/core/internal/grpcserver/authorization.go b/go/core/internal/grpcserver/authorization.go new file mode 100644 index 000000000..cb960c2cb --- /dev/null +++ b/go/core/internal/grpcserver/authorization.go @@ -0,0 +1,62 @@ +package grpcserver + +import ( + "context" + + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" + "github.com/kagent-dev/kagent/go/core/pkg/auth" +) + +type authorizationServer struct { + apiv1alpha1.UnimplementedAuthorizationServiceServer + reviewer *kubeauth.AccessReviewer +} + +func (s *authorizationServer) CheckAccess(ctx context.Context, request *apiv1alpha1.CheckAccessRequest) (*apiv1alpha1.CheckAccessResponse, error) { + resourceTypes := map[apiv1alpha1.AuthorizationResourceType]string{ + apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE: auth.ResourceAgentTemplate, + apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_HARNESS: auth.ResourceHarness, + apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG: auth.ResourceModelConfig, + } + verbs := map[apiv1alpha1.AuthorizationVerb]auth.Verb{ + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET: auth.VerbGet, + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE: auth.VerbCreate, + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE: auth.VerbUpdate, + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_DELETE: auth.VerbDelete, + } + authorizationVerbs := map[auth.Verb]apiv1alpha1.AuthorizationVerb{ + auth.VerbGet: apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET, + auth.VerbCreate: apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE, + auth.VerbUpdate: apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE, + auth.VerbDelete: apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_DELETE, + } + requestVerbs := make([]auth.Verb, len(request.GetVerbs())) + for i, verb := range request.GetVerbs() { + requestVerbs[i] = verbs[verb] + } + requestTargets := make([]kubeauth.ReviewTarget, len(request.GetTargets())) + for i, target := range request.GetTargets() { + requestTargets[i] = kubeauth.ReviewTarget{Namespace: target.GetNamespace(), Name: target.GetName()} + } + + results, err := s.reviewer.Review(ctx, resourceTypes[request.GetResourceType()], requestVerbs, requestTargets) + if err != nil { + return nil, err + } + + response := &apiv1alpha1.CheckAccessResponse{Results: make([]*apiv1alpha1.ResourceAccess, len(results))} + for i, result := range results { + target := &apiv1alpha1.AccessTarget{Namespace: result.Target.Namespace} + if result.Target.Name != "" { + name := result.Target.Name + target.Name = &name + } + allowedVerbs := make([]apiv1alpha1.AuthorizationVerb, len(result.AllowedVerbs)) + for j, verb := range result.AllowedVerbs { + allowedVerbs[j] = authorizationVerbs[verb] + } + response.Results[i] = &apiv1alpha1.ResourceAccess{Target: target, AllowedVerbs: allowedVerbs} + } + return response, nil +} diff --git a/go/core/internal/grpcserver/authorization_test.go b/go/core/internal/grpcserver/authorization_test.go new file mode 100644 index 000000000..ecff6e1c4 --- /dev/null +++ b/go/core/internal/grpcserver/authorization_test.go @@ -0,0 +1,104 @@ +package grpcserver + +import ( + "context" + "net" + "testing" + + apiauthorization "github.com/kagent-dev/kagent/go/api/authorization" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" + pkgauth "github.com/kagent-dev/kagent/go/core/pkg/auth" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/proto" +) + +type accessReviewScopeCall struct { + verb pkgauth.Verb + resourceType string +} + +type accessReviewAuthorizer struct { + scopeCalls []accessReviewScopeCall +} + +func (*accessReviewAuthorizer) Check(context.Context, pkgauth.Principal, pkgauth.Verb, pkgauth.Resource) error { + return nil +} + +func (a *accessReviewAuthorizer) Scope(_ context.Context, _ pkgauth.Principal, verb pkgauth.Verb, resourceType string) (apiauthorization.AuthorizationScope, error) { + a.scopeCalls = append(a.scopeCalls, accessReviewScopeCall{verb: verb, resourceType: resourceType}) + return apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAll}, nil +} + +func TestAuthorizationServiceGeneratedClient(t *testing.T) { + authorizer := &accessReviewAuthorizer{} + listener := bufconn.Listen(DefaultMaxMessageSize) + server, err := New(Config{ + Listener: listener, + Registerer: prometheus.NewRegistry(), + Authenticator: &authimpl.UnsecureAuthenticator{}, + SystemService: testSystemService(), + AuthorizationService: kubeauth.NewAccessReviewer(authorizer), + }) + require.NoError(t, err) + serverContext, cancelServer := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- server.Start(serverContext) }() + t.Cleanup(func() { + cancelServer() + assert.NoError(t, <-done) + }) + + connection, err := grpc.NewClient( + "passthrough:///bufnet", + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = connection.Close() }) + client := apiv1alpha1.NewAuthorizationServiceClient(connection) + name := "assistant" + + response, err := client.CheckAccess(t.Context(), &apiv1alpha1.CheckAccessRequest{ + ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE, + Verbs: []apiv1alpha1.AuthorizationVerb{ + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE, + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE, + }, + Targets: []*apiv1alpha1.AccessTarget{ + {Namespace: "team-a", Name: &name}, + {Namespace: "team-b"}, + }, + }) + require.NoError(t, err) + want := &apiv1alpha1.CheckAccessResponse{ + Results: []*apiv1alpha1.ResourceAccess{ + { + Target: &apiv1alpha1.AccessTarget{Namespace: "team-a", Name: &name}, + AllowedVerbs: []apiv1alpha1.AuthorizationVerb{ + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE, + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE, + }, + }, + { + Target: &apiv1alpha1.AccessTarget{Namespace: "team-b"}, + AllowedVerbs: []apiv1alpha1.AuthorizationVerb{ + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE, + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE, + }, + }, + }, + } + assert.True(t, proto.Equal(want, response), "response = %v, want %v", response, want) + assert.Equal(t, []accessReviewScopeCall{ + {verb: pkgauth.VerbUpdate, resourceType: pkgauth.ResourceAgentTemplate}, + {verb: pkgauth.VerbCreate, resourceType: pkgauth.ResourceAgentTemplate}, + }, authorizer.scopeCalls) +} diff --git a/go/core/internal/grpcserver/policy.go b/go/core/internal/grpcserver/policy.go index b296d8f44..4127be9f4 100644 --- a/go/core/internal/grpcserver/policy.go +++ b/go/core/internal/grpcserver/policy.go @@ -57,6 +57,7 @@ func DefaultMethodPolicies() MethodPolicies { apiv1alpha1.HarnessService_ListHarnesses_FullMethodName: auth.AccessRead, apiv1alpha1.HarnessService_CreateHarness_FullMethodName: auth.AccessCreate, apiv1alpha1.HarnessService_DeleteHarness_FullMethodName: auth.AccessDelete, + apiv1alpha1.AuthorizationService_CheckAccess_FullMethodName: auth.AccessRead, } policies[apiv1alpha1.AgentInstanceService_CreateAgentInstance_FullMethodName] = auth.AccessCreate policies[apiv1alpha1.AgentInstanceService_GetAgentInstance_FullMethodName] = auth.AccessRead diff --git a/go/core/internal/grpcserver/policy_test.go b/go/core/internal/grpcserver/policy_test.go index 88588d41f..507bb49bc 100644 --- a/go/core/internal/grpcserver/policy_test.go +++ b/go/core/internal/grpcserver/policy_test.go @@ -45,6 +45,12 @@ func TestAgentInstanceServicePoliciesMatchTheirEffect(t *testing.T) { } } +func TestAuthorizationServicePolicyIsRead(t *testing.T) { + if got := DefaultMethodPolicies()[apiv1alpha1.AuthorizationService_CheckAccess_FullMethodName]; got != pkgauth.AccessRead { + t.Fatalf("CheckAccess policy = %q, want %q", got, pkgauth.AccessRead) + } +} + // TestReadOnlyShareCannotRenameAConversation is the property the policy entry // exists for, measured through the interceptor rather than read off the table: a // read-only share link may open a conversation and must not be able to retitle diff --git a/go/core/internal/grpcserver/protovalidate_test.go b/go/core/internal/grpcserver/protovalidate_test.go index 93c5c19b9..82f8f30a4 100644 --- a/go/core/internal/grpcserver/protovalidate_test.go +++ b/go/core/internal/grpcserver/protovalidate_test.go @@ -69,6 +69,90 @@ func TestAgentInstanceRequestValidation(t *testing.T) { } } +func TestCheckAccessRequestValidation(t *testing.T) { + validator, err := protovalidate.New() + if err != nil { + t.Fatal(err) + } + name := "assistant" + emptyName := "" + targets := make([]*apiv1alpha1.AccessTarget, 101) + for i := range targets { + targets[i] = &apiv1alpha1.AccessTarget{Namespace: "team-a"} + } + for _, test := range []struct { + name string + request *apiv1alpha1.CheckAccessRequest + valid bool + }{ + { + name: "named and namespace targets", + request: &apiv1alpha1.CheckAccessRequest{ + ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE, + Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE, apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE}, + Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a", Name: &name}, {Namespace: "team-b"}}, + }, + valid: true, + }, + { + name: "harness create and delete", + request: &apiv1alpha1.CheckAccessRequest{ + ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_HARNESS, + Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE, apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_DELETE}, + Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a"}}, + }, + valid: true, + }, + { + name: "missing resource type", + request: &apiv1alpha1.CheckAccessRequest{Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE}, Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a"}}}, + }, + { + name: "missing verbs", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG, Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a"}}}, + }, + { + name: "duplicate verbs", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG, Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET, apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET}, Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a"}}}, + }, + { + name: "unknown verb", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG, Verbs: []apiv1alpha1.AuthorizationVerb{99}, Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a"}}}, + }, + { + name: "missing targets", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG, Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET}}, + }, + { + name: "nil target", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG, Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET}, Targets: []*apiv1alpha1.AccessTarget{nil}}, + }, + { + name: "too many targets", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG, Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET}, Targets: targets}, + }, + { + name: "invalid namespace", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_HARNESS, Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_DELETE}, Targets: []*apiv1alpha1.AccessTarget{{Namespace: "NOT A NAMESPACE"}}}, + }, + { + name: "present empty name", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG, Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_GET}, Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a", Name: &emptyName}}}, + }, + { + name: "unsupported harness verb", + request: &apiv1alpha1.CheckAccessRequest{ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_HARNESS, Verbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE}, Targets: []*apiv1alpha1.AccessTarget{{Namespace: "team-a", Name: &name}}}, + }, + } { + t.Run(test.name, func(t *testing.T) { + err := validator.Validate(test.request) + if (err == nil) != test.valid { + t.Fatalf("Validate() error = %v, valid = %t", err, test.valid) + } + }) + } +} + func TestInvalidInstanceAndCheckpointIDsNeverReachHandlers(t *testing.T) { validator, err := protovalidate.New() if err != nil { diff --git a/go/core/internal/grpcserver/server.go b/go/core/internal/grpcserver/server.go index f591b1973..30ac472c7 100644 --- a/go/core/internal/grpcserver/server.go +++ b/go/core/internal/grpcserver/server.go @@ -17,6 +17,7 @@ import ( "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/kagent/go/core/internal/service/agentinstance" "github.com/kagent-dev/kagent/go/core/internal/service/checkpoint" + "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" "github.com/kagent-dev/kagent/go/core/internal/service/kubecrud" memoryservice "github.com/kagent-dev/kagent/go/core/internal/service/memory" modelservice "github.com/kagent-dev/kagent/go/core/internal/service/model" @@ -59,6 +60,7 @@ type Config struct { AgentInstanceService *agentinstance.Service CheckpointService *checkpoint.Service ScheduledRunService *scheduledrun.Service + AuthorizationService *kubeauth.AccessReviewer A2AHandler a2asrv.RequestHandler // RegisterServices registers services core does not own. Called during New, // because gRPC requires every service to be registered before Serve. @@ -155,6 +157,9 @@ func New(config Config) (*Server, error) { if config.CheckpointService != nil { apiv1alpha1.RegisterCheckpointServiceServer(grpcServer, &checkpointServer{service: config.CheckpointService}) } + if config.AuthorizationService != nil { + apiv1alpha1.RegisterAuthorizationServiceServer(grpcServer, &authorizationServer{reviewer: config.AuthorizationService}) + } if config.A2AHandler != nil { a2agrpc.NewHandler(config.A2AHandler).RegisterWith(grpcServer) } diff --git a/go/core/internal/service/kubeauth/review.go b/go/core/internal/service/kubeauth/review.go new file mode 100644 index 000000000..94abf1651 --- /dev/null +++ b/go/core/internal/service/kubeauth/review.go @@ -0,0 +1,69 @@ +package kubeauth + +import ( + "context" + + "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" + "github.com/kagent-dev/kagent/go/core/pkg/auth" +) + +type AccessReviewer struct { + authorizer auth.CollectionAuthorizer +} + +type ReviewTarget struct { + Namespace string + Name string +} + +type ReviewResult struct { + Target ReviewTarget + AllowedVerbs []auth.Verb +} + +func NewAccessReviewer(authorizer auth.CollectionAuthorizer) *AccessReviewer { + return &AccessReviewer{authorizer: authorizer} +} + +func (r *AccessReviewer) Review(ctx context.Context, resourceType string, verbs []auth.Verb, targets []ReviewTarget) ([]ReviewResult, error) { + session, ok := auth.AuthSessionFrom(ctx) + if !ok { + return nil, serviceerrors.NewUnauthenticated("Failed to get authenticated principal", nil) + } + principal := session.Principal() + + results := make([]ReviewResult, len(targets)) + for i, target := range targets { + results[i].Target = target + } + + for _, verb := range verbs { + var matcher *Matcher + for i, target := range targets { + var allowed bool + if target.Name != "" { + allowed = r.authorizer.Check(ctx, principal, verb, auth.Resource{ + Type: resourceType, Namespace: target.Namespace, Name: target.Name, + }) == nil + } else { + if matcher == nil { + scope, err := r.authorizer.Scope(ctx, principal, verb, resourceType) + if err != nil { + return nil, serviceerrors.NewUnavailable("Failed to read the "+resourceType+" authorization scope", err) + } + compiled, err := CompileScope(scope) + if err != nil { + return nil, serviceerrors.NewInternal("Failed to apply the "+resourceType+" authorization scope", err) + } + matcher = &compiled + } + allowed = matcher.MatchesAnyName(target.Namespace) + } + if allowed { + results[i].AllowedVerbs = append(results[i].AllowedVerbs, verb) + } + } + } + + return results, nil +} diff --git a/go/core/internal/service/kubeauth/review_test.go b/go/core/internal/service/kubeauth/review_test.go new file mode 100644 index 000000000..b04c8794f --- /dev/null +++ b/go/core/internal/service/kubeauth/review_test.go @@ -0,0 +1,174 @@ +package kubeauth_test + +import ( + "context" + "errors" + "testing" + + apiauthorization "github.com/kagent-dev/kagent/go/api/authorization" + "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" + "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" + "github.com/kagent-dev/kagent/go/core/pkg/auth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testSession struct{ principal auth.Principal } + +func (s testSession) Principal() auth.Principal { return s.principal } + +type scopeCall struct { + principal auth.Principal + verb auth.Verb + resourceType string +} + +type checkCall struct { + principal auth.Principal + verb auth.Verb + resource auth.Resource +} + +type checkKey struct { + verb auth.Verb + resourceType, namespace, name string +} + +type testAuthorizer struct { + scopes map[auth.Verb]apiauthorization.AuthorizationScope + scopeErrs map[auth.Verb]error + scopeCalls []scopeCall + checkErrs map[checkKey]error + checkCalls []checkCall +} + +func (a *testAuthorizer) Check(_ context.Context, principal auth.Principal, verb auth.Verb, resource auth.Resource) error { + a.checkCalls = append(a.checkCalls, checkCall{principal: principal, verb: verb, resource: resource}) + return a.checkErrs[checkKey{verb: verb, resourceType: resource.Type, namespace: resource.Namespace, name: resource.Name}] +} + +func (a *testAuthorizer) Scope(_ context.Context, principal auth.Principal, verb auth.Verb, resourceType string) (apiauthorization.AuthorizationScope, error) { + a.scopeCalls = append(a.scopeCalls, scopeCall{principal: principal, verb: verb, resourceType: resourceType}) + return a.scopes[verb], a.scopeErrs[verb] +} + +func TestCheckAccessMatrix(t *testing.T) { + principal := auth.Principal{User: auth.User{ID: "reader"}} + ctx := auth.AuthSessionTo(t.Context(), testSession{principal: principal}) + denied := errors.New("denied") + authorizer := &testAuthorizer{scopes: map[auth.Verb]apiauthorization.AuthorizationScope{ + auth.VerbUpdate: { + Kind: apiauthorization.ScopeAnyOf, + AnyOf: []apiauthorization.ScopeClause{{All: []apiauthorization.ScopePredicate{ + {Attribute: apiauthorization.AttributeNamespace, Operator: apiauthorization.ScopeIn, Values: []string{"team-a"}}, + {Attribute: apiauthorization.AttributeName, Operator: apiauthorization.ScopeIn, Values: []string{"assistant"}}, + }}}, + }, + auth.VerbCreate: { + Kind: apiauthorization.ScopeAnyOf, + AnyOf: []apiauthorization.ScopeClause{{All: []apiauthorization.ScopePredicate{ + {Attribute: apiauthorization.AttributeNamespace, Operator: apiauthorization.ScopeIn, Values: []string{"team-a"}}, + }}}, + }, + }, checkErrs: map[checkKey]error{ + {verb: auth.VerbUpdate, resourceType: auth.ResourceAgentTemplate, namespace: "team-b", name: "assistant"}: denied, + {verb: auth.VerbCreate, resourceType: auth.ResourceAgentTemplate, namespace: "team-b", name: "assistant"}: denied, + {verb: auth.VerbUpdate, resourceType: auth.ResourceAgentTemplate, namespace: "team-a", name: "other"}: denied, + }} + targets := []kubeauth.ReviewTarget{ + {Namespace: "team-a", Name: "assistant"}, + {Namespace: "team-b", Name: "assistant"}, + {Namespace: "team-a"}, + {Namespace: "team-a", Name: "other"}, + } + + results, err := kubeauth.NewAccessReviewer(authorizer).Review( + ctx, + auth.ResourceAgentTemplate, + []auth.Verb{auth.VerbUpdate, auth.VerbCreate}, + targets, + ) + require.NoError(t, err) + assert.Equal(t, []kubeauth.ReviewResult{ + {Target: targets[0], AllowedVerbs: []auth.Verb{auth.VerbUpdate, auth.VerbCreate}}, + {Target: targets[1]}, + {Target: targets[2], AllowedVerbs: []auth.Verb{auth.VerbUpdate, auth.VerbCreate}}, + {Target: targets[3], AllowedVerbs: []auth.Verb{auth.VerbCreate}}, + }, results) + assert.Equal(t, []scopeCall{ + {principal: principal, verb: auth.VerbUpdate, resourceType: auth.ResourceAgentTemplate}, + {principal: principal, verb: auth.VerbCreate, resourceType: auth.ResourceAgentTemplate}, + }, authorizer.scopeCalls) + assert.Equal(t, []checkCall{ + {principal: principal, verb: auth.VerbUpdate, resource: auth.Resource{Type: auth.ResourceAgentTemplate, Namespace: "team-a", Name: "assistant"}}, + {principal: principal, verb: auth.VerbUpdate, resource: auth.Resource{Type: auth.ResourceAgentTemplate, Namespace: "team-b", Name: "assistant"}}, + {principal: principal, verb: auth.VerbUpdate, resource: auth.Resource{Type: auth.ResourceAgentTemplate, Namespace: "team-a", Name: "other"}}, + {principal: principal, verb: auth.VerbCreate, resource: auth.Resource{Type: auth.ResourceAgentTemplate, Namespace: "team-a", Name: "assistant"}}, + {principal: principal, verb: auth.VerbCreate, resource: auth.Resource{Type: auth.ResourceAgentTemplate, Namespace: "team-b", Name: "assistant"}}, + {principal: principal, verb: auth.VerbCreate, resource: auth.Resource{Type: auth.ResourceAgentTemplate, Namespace: "team-a", Name: "other"}}, + }, authorizer.checkCalls) +} + +func TestCheckAccessNamedTargetsDoNotReadScope(t *testing.T) { + authorizer := &testAuthorizer{scopeErrs: map[auth.Verb]error{auth.VerbGet: errors.New("scope unavailable")}} + ctx := auth.AuthSessionTo(t.Context(), testSession{}) + target := kubeauth.ReviewTarget{Namespace: "team-a", Name: "assistant"} + + results, err := kubeauth.NewAccessReviewer(authorizer).Review( + ctx, + auth.ResourceAgentTemplate, + []auth.Verb{auth.VerbGet}, + []kubeauth.ReviewTarget{target}, + ) + require.NoError(t, err) + assert.Equal(t, []kubeauth.ReviewResult{{Target: target, AllowedVerbs: []auth.Verb{auth.VerbGet}}}, results) + assert.Empty(t, authorizer.scopeCalls) +} + +func TestCheckAccessScopeFailures(t *testing.T) { + tests := []struct { + name string + scope apiauthorization.AuthorizationScope + scopeError error + wantCode serviceerrors.Code + }{ + { + name: "authorizer unavailable", + scopeError: errors.New("unavailable"), + wantCode: serviceerrors.CodeUnavailable, + }, + { + name: "malformed scope", + scope: apiauthorization.AuthorizationScope{}, + wantCode: serviceerrors.CodeInternal, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + authorizer := &testAuthorizer{ + scopes: map[auth.Verb]apiauthorization.AuthorizationScope{auth.VerbCreate: test.scope}, + scopeErrs: map[auth.Verb]error{auth.VerbCreate: test.scopeError}, + } + ctx := auth.AuthSessionTo(t.Context(), testSession{}) + + _, err := kubeauth.NewAccessReviewer(authorizer).Review( + ctx, + auth.ResourceModelConfig, + []auth.Verb{auth.VerbCreate}, + []kubeauth.ReviewTarget{{Namespace: "team-a"}}, + ) + assert.True(t, serviceerrors.IsCode(err, test.wantCode), "error = %v", err) + }) + } +} + +func TestCheckAccessRequiresSession(t *testing.T) { + _, err := kubeauth.NewAccessReviewer(&testAuthorizer{}).Review( + t.Context(), + auth.ResourceAgentTemplate, + []auth.Verb{auth.VerbGet}, + []kubeauth.ReviewTarget{{Namespace: "team-a", Name: "assistant"}}, + ) + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeUnauthenticated), "error = %v", err) +} diff --git a/go/core/internal/service/kubeauth/scope.go b/go/core/internal/service/kubeauth/scope.go index 6d5fe6de2..bee7d5e8e 100644 --- a/go/core/internal/service/kubeauth/scope.go +++ b/go/core/internal/service/kubeauth/scope.go @@ -6,6 +6,7 @@ import ( apiauthorization "github.com/kagent-dev/kagent/go/api/authorization" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + utilvalidation "k8s.io/apimachinery/pkg/util/validation" ) // Matcher is a validated authorization scope that can be applied to Kubernetes objects. @@ -87,3 +88,33 @@ func (m Matcher) Matches(object metav1.Object) bool { } return false } + +// MatchesAnyName reports whether the scope contains any valid Kubernetes +// resource name in namespace. +func (m Matcher) MatchesAnyName(namespace string) bool { + if len(utilvalidation.IsDNS1123Label(namespace)) != 0 { + return false + } + if m.scope.Kind == apiauthorization.ScopeAll { + return true + } + for _, clause := range m.scope.AnyOf { + names := []string{"x"} + for _, predicate := range clause.All { + if predicate.Attribute == apiauthorization.AttributeName { + names = predicate.Values + break + } + } + for _, name := range names { + if len(utilvalidation.IsDNS1123Subdomain(name)) != 0 { + continue + } + object := &metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}} + if m.Matches(object) { + return true + } + } + } + return false +} diff --git a/go/core/internal/service/kubeauth/scope_test.go b/go/core/internal/service/kubeauth/scope_test.go index 292945017..615ebebf1 100644 --- a/go/core/internal/service/kubeauth/scope_test.go +++ b/go/core/internal/service/kubeauth/scope_test.go @@ -89,6 +89,63 @@ func TestMatcherOwnsCompiledScope(t *testing.T) { } } +func TestMatcherMatchesAnyName(t *testing.T) { + tests := []struct { + name string + namespace string + scope apiauthorization.AuthorizationScope + want bool + }{ + {name: "all", scope: apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAll}, want: true}, + {name: "none", scope: apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeNone}}, + {name: "invalid namespace", namespace: "NOT A NAMESPACE", scope: apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAll}}, + { + name: "namespace", + scope: apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAnyOf, AnyOf: []apiauthorization.ScopeClause{{All: []apiauthorization.ScopePredicate{{Attribute: apiauthorization.AttributeNamespace, Operator: apiauthorization.ScopeIn, Values: []string{"team-a"}}}}}}, + want: true, + }, + { + name: "different namespace", + scope: apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAnyOf, AnyOf: []apiauthorization.ScopeClause{{All: []apiauthorization.ScopePredicate{{Attribute: apiauthorization.AttributeNamespace, Operator: apiauthorization.ScopeIn, Values: []string{"team-b"}}}}}}, + }, + { + name: "intersecting names", + scope: apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAnyOf, AnyOf: []apiauthorization.ScopeClause{{All: []apiauthorization.ScopePredicate{ + {Attribute: apiauthorization.AttributeName, Operator: apiauthorization.ScopeIn, Values: []string{"agent-a", "agent-b"}}, + {Attribute: apiauthorization.AttributeName, Operator: apiauthorization.ScopeIn, Values: []string{"agent-b"}}, + }}}}, + want: true, + }, + { + name: "disjoint names", + scope: apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAnyOf, AnyOf: []apiauthorization.ScopeClause{{All: []apiauthorization.ScopePredicate{ + {Attribute: apiauthorization.AttributeName, Operator: apiauthorization.ScopeIn, Values: []string{"agent-a"}}, + {Attribute: apiauthorization.AttributeName, Operator: apiauthorization.ScopeIn, Values: []string{"agent-b"}}, + }}}}, + }, + { + name: "invalid resource name", + scope: apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAnyOf, AnyOf: []apiauthorization.ScopeClause{{All: []apiauthorization.ScopePredicate{{Attribute: apiauthorization.AttributeName, Operator: apiauthorization.ScopeIn, Values: []string{"NOT A NAME"}}}}}}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + namespace := test.namespace + if namespace == "" { + namespace = "team-a" + } + matcher, err := kubeauth.CompileScope(test.scope) + if err != nil { + t.Fatalf("CompileScope() error = %v", err) + } + if got := matcher.MatchesAnyName(namespace); got != test.want { + t.Fatalf("MatchesAnyName() = %v, want %v", got, test.want) + } + }) + } +} + func TestCompileScopeRejectsInvalidScopes(t *testing.T) { tests := []struct { name string diff --git a/go/core/internal/service/model/service.go b/go/core/internal/service/model/service.go index 47e93a9e8..f77365201 100644 --- a/go/core/internal/service/model/service.go +++ b/go/core/internal/service/model/service.go @@ -20,9 +20,6 @@ import ( var modelConfigGVK = v1alpha3.GroupVersion.WithKind("ModelConfig") -// modelConfigResource names ModelConfig in authorization decisions. -const modelConfigResource = "ModelConfig" - type Service struct { kubeClient client.Client modelConfigs *kubecrud.Service[*v1alpha3.ModelConfig, *v1alpha3.ModelConfigList] @@ -57,7 +54,7 @@ type DeleteRequest struct { func NewService(kubeClient client.Client, authorizer auth.CollectionAuthorizer, defaultNamespace string, options ...ServiceOption) *Service { service := &Service{ kubeClient: kubeClient, - modelConfigs: kubecrud.NewService(kubeClient, authorizer, &v1alpha3.ModelConfig{}, &v1alpha3.ModelConfigList{}, modelConfigResource), + modelConfigs: kubecrud.NewService(kubeClient, authorizer, &v1alpha3.ModelConfig{}, &v1alpha3.ModelConfigList{}, auth.ResourceModelConfig), defaultNamespace: defaultNamespace, } for _, option := range options { diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index 0457c1c9e..de87a252f 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -33,6 +33,7 @@ import ( v2mcp "github.com/kagent-dev/kagent/go/core/internal/mcp" "github.com/kagent-dev/kagent/go/core/internal/service/agentinstance" "github.com/kagent-dev/kagent/go/core/internal/service/checkpoint" + "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" "github.com/kagent-dev/kagent/go/core/internal/service/kubecrud" memoryservice "github.com/kagent-dev/kagent/go/core/internal/service/memory" modelservice "github.com/kagent-dev/kagent/go/core/internal/service/model" @@ -321,10 +322,11 @@ func Run(ctx context.Context, opts Options) error { MemoryService: memory, AgentInstanceService: instances, ScheduledRunService: schedules, + AuthorizationService: kubeauth.NewAccessReviewer(authorizer), // Both halves of the pair CreateAgentInstance names. Without these two // the only way to author a Harness or an AgentTemplate is kubectl. - AgentTemplateService: kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.AgentTemplate{}, &kagentv1alpha3.AgentTemplateList{}, "AgentTemplate"), - HarnessService: kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.Harness{}, &kagentv1alpha3.HarnessList{}, "Harness"), + AgentTemplateService: kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.AgentTemplate{}, &kagentv1alpha3.AgentTemplateList{}, auth.ResourceAgentTemplate), + HarnessService: kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.Harness{}, &kagentv1alpha3.HarnessList{}, auth.ResourceHarness), CheckpointService: checkpoints, A2AHandler: gateway, HTTPHandler: mux, diff --git a/go/core/pkg/auth/auth.go b/go/core/pkg/auth/auth.go index da3e8fd19..c23efee8a 100644 --- a/go/core/pkg/auth/auth.go +++ b/go/core/pkg/auth/auth.go @@ -18,6 +18,12 @@ const ( VerbDelete Verb = "delete" ) +const ( + ResourceAgentTemplate = "AgentTemplate" + ResourceHarness = "Harness" + ResourceModelConfig = "ModelConfig" +) + type Resource struct { Type string Namespace string @@ -83,9 +89,12 @@ const ( // Authz type Authorizer interface { + // Check returns nil only when the principal may perform the operation. Check(ctx context.Context, principal Principal, verb Verb, resource Resource) error } +// CollectionAuthorizer enumerates every namespace/name combination allowed for a verb. +// Scope must fail rather than return a partial result. type CollectionAuthorizer interface { Authorizer Scope(ctx context.Context, principal Principal, verb Verb, resourceType string) (authorization.AuthorizationScope, error) diff --git a/go/core/test/e2e/access_review_test.go b/go/core/test/e2e/access_review_test.go new file mode 100644 index 000000000..fa5b1bf1c --- /dev/null +++ b/go/core/test/e2e/access_review_test.go @@ -0,0 +1,48 @@ +package e2e_test + +import ( + "context" + "testing" + "time" + + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/proto" +) + +func TestE2EAccessReview(t *testing.T) { + t.Parallel() + connection, err := grpc.NewClient(interactionTarget(t), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = connection.Close() }) + ctx, cancel := context.WithTimeout(metadata.AppendToOutgoingContext(t.Context(), "x-user-id", "e2e"), time.Minute) + t.Cleanup(cancel) + name := "smoke" + + response, err := apiv1alpha1.NewAuthorizationServiceClient(connection).CheckAccess(ctx, &apiv1alpha1.CheckAccessRequest{ + ResourceType: apiv1alpha1.AuthorizationResourceType_AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE, + Verbs: []apiv1alpha1.AuthorizationVerb{ + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE, + apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE, + }, + Targets: []*apiv1alpha1.AccessTarget{ + {Namespace: "kagent"}, + {Namespace: "kagent", Name: &name}, + }, + }) + require.NoError(t, err) + want := &apiv1alpha1.CheckAccessResponse{Results: []*apiv1alpha1.ResourceAccess{ + { + Target: &apiv1alpha1.AccessTarget{Namespace: "kagent"}, + AllowedVerbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE, apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE}, + }, + { + Target: &apiv1alpha1.AccessTarget{Namespace: "kagent", Name: &name}, + AllowedVerbs: []apiv1alpha1.AuthorizationVerb{apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_CREATE, apiv1alpha1.AuthorizationVerb_AUTHORIZATION_VERB_UPDATE}, + }, + }} + require.True(t, proto.Equal(want, response), "response = %v, want %v", response, want) +} diff --git a/proto/kagent/api/v1alpha1/authorization.proto b/proto/kagent/api/v1alpha1/authorization.proto new file mode 100644 index 000000000..181e44cc1 --- /dev/null +++ b/proto/kagent/api/v1alpha1/authorization.proto @@ -0,0 +1,79 @@ +syntax = "proto3"; + +package kagent.api.v1alpha1; + +import "buf/validate/validate.proto"; + +option go_package = "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1;apiv1alpha1"; + +// AuthorizationService answers advisory access checks for catalog operations. +// Catalog operations remain authoritative. +service AuthorizationService { + rpc CheckAccess(CheckAccessRequest) returns (CheckAccessResponse); +} + +enum AuthorizationResourceType { + AUTHORIZATION_RESOURCE_TYPE_UNSPECIFIED = 0; + AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE = 1; + AUTHORIZATION_RESOURCE_TYPE_HARNESS = 2; + AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG = 3; +} + +enum AuthorizationVerb { + AUTHORIZATION_VERB_UNSPECIFIED = 0; + AUTHORIZATION_VERB_GET = 1; + AUTHORIZATION_VERB_CREATE = 2; + AUTHORIZATION_VERB_UPDATE = 3; + AUTHORIZATION_VERB_DELETE = 4; +} + +message CheckAccessRequest { + option (buf.validate.message).cel = { + id: "supported_resource_verbs" + message: "Harness supports only CREATE and DELETE access reviews" + expression: "this.resource_type != 2 || this.verbs.all(verb, verb == 2 || verb == 4)" + }; + AuthorizationResourceType resource_type = 1 [(buf.validate.field).enum = { + defined_only: true + not_in: 0 + }]; + repeated AuthorizationVerb verbs = 2 [(buf.validate.field).repeated = { + min_items: 1 + max_items: 4 + unique: true + items: { + enum: { + defined_only: true + not_in: 0 + } + } + }]; + repeated AccessTarget targets = 3 [(buf.validate.field).repeated = { + min_items: 1 + max_items: 100 + }]; +} + +message AccessTarget { + string namespace = 1 [(buf.validate.field).string = { + min_len: 1 + max_len: 63 + pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" + }]; + // When present, the review checks this exact resource identity. When omitted, + // it checks whether any valid resource name in the namespace is permitted. + optional string name = 2 [(buf.validate.field).string = { + min_len: 1 + max_len: 253 + pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$" + }]; +} + +message CheckAccessResponse { + repeated ResourceAccess results = 1; +} + +message ResourceAccess { + AccessTarget target = 1; + repeated AuthorizationVerb allowed_verbs = 2; +} diff --git a/ui/src/generated/kagent/api/v1alpha1/authorization_pb.ts b/ui/src/generated/kagent/api/v1alpha1/authorization_pb.ts new file mode 100644 index 000000000..8c324f7e1 --- /dev/null +++ b/ui/src/generated/kagent/api/v1alpha1/authorization_pb.ts @@ -0,0 +1,191 @@ +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts" +// @generated from file kagent/api/v1alpha1/authorization.proto (package kagent.api.v1alpha1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file kagent/api/v1alpha1/authorization.proto. + */ +export const file_kagent_api_v1alpha1_authorization: GenFile = /*@__PURE__*/ + fileDesc("CidrYWdlbnQvYXBpL3YxYWxwaGExL2F1dGhvcml6YXRpb24ucHJvdG8SE2thZ2VudC5hcGkudjFhbHBoYTEimgMKEkNoZWNrQWNjZXNzUmVxdWVzdBJRCg1yZXNvdXJjZV90eXBlGAEgASgOMi4ua2FnZW50LmFwaS52MWFscGhhMS5BdXRob3JpemF0aW9uUmVzb3VyY2VUeXBlQgq6SAeCAQQQASAAEkwKBXZlcmJzGAIgAygOMiYua2FnZW50LmFwaS52MWFscGhhMS5BdXRob3JpemF0aW9uVmVyYkIVukgSkgEPCAEQBBgBIgeCAQQQASAAEj4KB3RhcmdldHMYAyADKAsyIS5rYWdlbnQuYXBpLnYxYWxwaGExLkFjY2Vzc1RhcmdldEIKukgHkgEECAEQZDqiAbpIngEamwEKGHN1cHBvcnRlZF9yZXNvdXJjZV92ZXJicxI2SGFybmVzcyBzdXBwb3J0cyBvbmx5IENSRUFURSBhbmQgREVMRVRFIGFjY2VzcyByZXZpZXdzGkd0aGlzLnJlc291cmNlX3R5cGUgIT0gMiB8fCB0aGlzLnZlcmJzLmFsbCh2ZXJiLCB2ZXJiID09IDIgfHwgdmVyYiA9PSA0KSK5AQoMQWNjZXNzVGFyZ2V0Ej0KCW5hbWVzcGFjZRgBIAEoCUIqukgnciUQARg/Mh9eW2EtejAtOV0oWy1hLXowLTldKlthLXowLTldKT8kEmEKBG5hbWUYAiABKAlCTrpIS3JJEAEY/QEyQl5bYS16MC05XShbLWEtejAtOV0qW2EtejAtOV0pPyhbLl1bYS16MC05XShbLWEtejAtOV0qW2EtejAtOV0pPykqJEgAiAEBQgcKBV9uYW1lIksKE0NoZWNrQWNjZXNzUmVzcG9uc2USNAoHcmVzdWx0cxgBIAMoCzIjLmthZ2VudC5hcGkudjFhbHBoYTEuUmVzb3VyY2VBY2Nlc3MiggEKDlJlc291cmNlQWNjZXNzEjEKBnRhcmdldBgBIAEoCzIhLmthZ2VudC5hcGkudjFhbHBoYTEuQWNjZXNzVGFyZ2V0Ej0KDWFsbG93ZWRfdmVyYnMYAiADKA4yJi5rYWdlbnQuYXBpLnYxYWxwaGExLkF1dGhvcml6YXRpb25WZXJiKs8BChlBdXRob3JpemF0aW9uUmVzb3VyY2VUeXBlEisKJ0FVVEhPUklaQVRJT05fUkVTT1VSQ0VfVFlQRV9VTlNQRUNJRklFRBAAEi4KKkFVVEhPUklaQVRJT05fUkVTT1VSQ0VfVFlQRV9BR0VOVF9URU1QTEFURRABEicKI0FVVEhPUklaQVRJT05fUkVTT1VSQ0VfVFlQRV9IQVJORVNTEAISLAooQVVUSE9SSVpBVElPTl9SRVNPVVJDRV9UWVBFX01PREVMX0NPTkZJRxADKrABChFBdXRob3JpemF0aW9uVmVyYhIiCh5BVVRIT1JJWkFUSU9OX1ZFUkJfVU5TUEVDSUZJRUQQABIaChZBVVRIT1JJWkFUSU9OX1ZFUkJfR0VUEAESHQoZQVVUSE9SSVpBVElPTl9WRVJCX0NSRUFURRACEh0KGUFVVEhPUklaQVRJT05fVkVSQl9VUERBVEUQAxIdChlBVVRIT1JJWkFUSU9OX1ZFUkJfREVMRVRFEAQyeAoUQXV0aG9yaXphdGlvblNlcnZpY2USYAoLQ2hlY2tBY2Nlc3MSJy5rYWdlbnQuYXBpLnYxYWxwaGExLkNoZWNrQWNjZXNzUmVxdWVzdBooLmthZ2VudC5hcGkudjFhbHBoYTEuQ2hlY2tBY2Nlc3NSZXNwb25zZUJJWkdnaXRodWIuY29tL2thZ2VudC1kZXYva2FnZW50L2dvL2FwaS9nZW4va2FnZW50L2FwaS92MWFscGhhMTthcGl2MWFscGhhMWIGcHJvdG8z", [file_buf_validate_validate]); + +/** + * @generated from message kagent.api.v1alpha1.CheckAccessRequest + */ +export type CheckAccessRequest = Message<"kagent.api.v1alpha1.CheckAccessRequest"> & { + /** + * @generated from field: kagent.api.v1alpha1.AuthorizationResourceType resource_type = 1; + */ + resourceType: AuthorizationResourceType; + + /** + * @generated from field: repeated kagent.api.v1alpha1.AuthorizationVerb verbs = 2; + */ + verbs: AuthorizationVerb[]; + + /** + * @generated from field: repeated kagent.api.v1alpha1.AccessTarget targets = 3; + */ + targets: AccessTarget[]; +}; + +/** + * Describes the message kagent.api.v1alpha1.CheckAccessRequest. + * Use `create(CheckAccessRequestSchema)` to create a new message. + */ +export const CheckAccessRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1alpha1_authorization, 0); + +/** + * @generated from message kagent.api.v1alpha1.AccessTarget + */ +export type AccessTarget = Message<"kagent.api.v1alpha1.AccessTarget"> & { + /** + * @generated from field: string namespace = 1; + */ + namespace: string; + + /** + * When present, the review checks this exact resource identity. When omitted, + * it checks whether any valid resource name in the namespace is permitted. + * + * @generated from field: optional string name = 2; + */ + name?: string | undefined; +}; + +/** + * Describes the message kagent.api.v1alpha1.AccessTarget. + * Use `create(AccessTargetSchema)` to create a new message. + */ +export const AccessTargetSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1alpha1_authorization, 1); + +/** + * @generated from message kagent.api.v1alpha1.CheckAccessResponse + */ +export type CheckAccessResponse = Message<"kagent.api.v1alpha1.CheckAccessResponse"> & { + /** + * @generated from field: repeated kagent.api.v1alpha1.ResourceAccess results = 1; + */ + results: ResourceAccess[]; +}; + +/** + * Describes the message kagent.api.v1alpha1.CheckAccessResponse. + * Use `create(CheckAccessResponseSchema)` to create a new message. + */ +export const CheckAccessResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1alpha1_authorization, 2); + +/** + * @generated from message kagent.api.v1alpha1.ResourceAccess + */ +export type ResourceAccess = Message<"kagent.api.v1alpha1.ResourceAccess"> & { + /** + * @generated from field: kagent.api.v1alpha1.AccessTarget target = 1; + */ + target?: AccessTarget | undefined; + + /** + * @generated from field: repeated kagent.api.v1alpha1.AuthorizationVerb allowed_verbs = 2; + */ + allowedVerbs: AuthorizationVerb[]; +}; + +/** + * Describes the message kagent.api.v1alpha1.ResourceAccess. + * Use `create(ResourceAccessSchema)` to create a new message. + */ +export const ResourceAccessSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1alpha1_authorization, 3); + +/** + * @generated from enum kagent.api.v1alpha1.AuthorizationResourceType + */ +export enum AuthorizationResourceType { + /** + * @generated from enum value: AUTHORIZATION_RESOURCE_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: AUTHORIZATION_RESOURCE_TYPE_AGENT_TEMPLATE = 1; + */ + AGENT_TEMPLATE = 1, + + /** + * @generated from enum value: AUTHORIZATION_RESOURCE_TYPE_HARNESS = 2; + */ + HARNESS = 2, + + /** + * @generated from enum value: AUTHORIZATION_RESOURCE_TYPE_MODEL_CONFIG = 3; + */ + MODEL_CONFIG = 3, +} + +/** + * Describes the enum kagent.api.v1alpha1.AuthorizationResourceType. + */ +export const AuthorizationResourceTypeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_kagent_api_v1alpha1_authorization, 0); + +/** + * @generated from enum kagent.api.v1alpha1.AuthorizationVerb + */ +export enum AuthorizationVerb { + /** + * @generated from enum value: AUTHORIZATION_VERB_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: AUTHORIZATION_VERB_GET = 1; + */ + GET = 1, + + /** + * @generated from enum value: AUTHORIZATION_VERB_CREATE = 2; + */ + CREATE = 2, + + /** + * @generated from enum value: AUTHORIZATION_VERB_UPDATE = 3; + */ + UPDATE = 3, + + /** + * @generated from enum value: AUTHORIZATION_VERB_DELETE = 4; + */ + DELETE = 4, +} + +/** + * Describes the enum kagent.api.v1alpha1.AuthorizationVerb. + */ +export const AuthorizationVerbSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_kagent_api_v1alpha1_authorization, 1); + +/** + * AuthorizationService answers advisory access checks for catalog operations. + * Catalog operations remain authoritative. + * + * @generated from service kagent.api.v1alpha1.AuthorizationService + */ +export const AuthorizationService: GenService<{ + /** + * @generated from rpc kagent.api.v1alpha1.AuthorizationService.CheckAccess + */ + checkAccess: { + methodKind: "unary"; + input: typeof CheckAccessRequestSchema; + output: typeof CheckAccessResponseSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_kagent_api_v1alpha1_authorization, 0); +