diff --git a/Dockerfile b/Dockerfile index c5b7cf8..c293c72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,14 +9,48 @@ RUN apt-get update && \ unzip protoc-$PROTOVER-$PROTOARCH.zip -d /usr/ WORKDIR /tmp/compile COPY . . -RUN go mod download && \ - go get google.golang.org/protobuf/cmd/protoc-gen-go \ - google.golang.org/grpc/cmd/protoc-gen-go-grpc && \ +RUN go install google.golang.org/protobuf/cmd/protoc-gen-go \ + google.golang.org/grpc/cmd/protoc-gen-go-grpc && \ go generate -v tools.go && \ CGO_ENABLED=0 go build -v -ldflags="-s -w" -o /usr/bin/cacheroach . -FROM scratch +# Create a single-binary docker image, including a set of core CA +# certificates so that we can call out to any external APIs. +FROM scratch AS cacheroach WORKDIR /data/ ENTRYPOINT ["/usr/bin/cacheroach"] COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /usr/bin/cacheroach /usr/bin/ + +# This is a default configuration for Google Cloud Run. It assumes that +# you have the secret manager API installed. A named secret should +# contain a tar.gz file that has files with the @filename values below. +# +# The OIDC integration is optional, but if you're already deploying +# into GCR, you need only to create credentials for an OAuth2 webapp. +FROM cacheroach AS cloudrun +# Expect $PORT from Cloud Run environment. +ENV CACHE_MEMORY="128" \ + CONNECT="@connect" \ + GCLOUD_SECRET_NAME="" \ + HMAC="@hmac" \ + OIDC_CLIENT_ID="@oidc_client_id" \ + OIDC_CLIENT_SECRET="@oidc_client_secret" \ + OIDC_DOMAINS="cockroachlabs.com" \ + OIDC_ISSUER="https://accounts.google.com" +ENTRYPOINT [ \ + "/usr/bin/cacheroach", \ + "start", \ + "--assumeSecure", \ + "--bindAddr", ":$PORT", \ + "--cacheMemory", "$CACHE_MEMORY", \ + "--connect", "$CONNECT", \ + "--oidcClientID", "$OIDC_CLIENT_ID", \ + "--oidcClientSecret", "$OIDC_CLIENT_SECRET", \ + "--oidcDomains", "$OIDC_DOMAINS", \ + "--oidcIssuer", "$OIDC_ISSUER", \ + "--signingKey", "$HMAC" \ +] + +# Set a default target for e.g. DockerHub builds. +FROM cacheroach \ No newline at end of file diff --git a/Dockerfile.gcr b/Dockerfile.gcr deleted file mode 100644 index 8d85db1..0000000 --- a/Dockerfile.gcr +++ /dev/null @@ -1,42 +0,0 @@ -# This Dockerfile is used to build an image that can drop into -# Google Cloud Run. -# -# Prerequisites: -# * Add https://cloud.google.com/secret-manager to your project -# * Upload a tar.gz file containing the following files -# * ca.crt: Your CockroachDB CA certificate -# * hmac: Some BASE64-encoded random data for signing JWT tokens -# * connect: A plain-text file containing the connection URL -# * Create a "Serverless VPC Connector" unless you access your -# database over the public Internet. -# * If using Cockroach Cloud, enable VPC peering between your project -# and Cockroach Cloud. -# * Enable HTTP/2 (H2C) support in the service definition. -# * Set the GCLOUD_SECRET_NAME to the long-form name of the -# uploaded secret. -# * Adjust the CACHE_MEMORY environment variable to reflect your -# preferred container size. -# -# You may specify additional flags in the "container arguments". -# -# The disk cache is not enabled, since the Cloud Run disk is an -# in-memory filesystem that counts against the total memory budget. - -FROM golang:1.15 AS builder -WORKDIR /tmp/compile -COPY . . -RUN CGO_ENABLED=0 go build -v -ldflags="-s -w" -o /usr/bin/cacheroach . - -FROM scratch -# Expect $PORT from Cloud Run environment. -ENV CACHE_MEMORY="128" CONNECT="@connect" GCLOUD_SECRET_NAME="" HMAC="@hmac" -ENTRYPOINT [ \ - "/usr/bin/cacheroach", \ - "start", \ - "--bindAddr", ":$PORT", \ - "--cacheMemory", "$CACHE_MEMORY", \ - "--connect", "$CONNECT", \ - "--signingKey", "$HMAC" \ -] -COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ -COPY --from=builder /usr/bin/cacheroach /usr/bin/ diff --git a/README.md b/README.md index 81a1180..edebd8e 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ cacheroach -v -c root.cfg session delegate --for --on tenant --id # Set a default tenant id to minimize typing cacheroach tenant default -# Upload the cacheroach binary +# Upload a file echo "Hello World." > hello.txt cacheroach -v file put / hello.txt # Look at HTTP vhost mapping @@ -82,8 +82,13 @@ Cacheroach uses a "capability, delegate, target" approach to authorization. A [Principal](./api/principal.proto) may have zero or more durable [Sessions](./api/session.proto) which grant the principal the permission to perform operations within the system. -These sessions are exposed as signed [JWT tokens](https://jwt.io). Active sessions are maintained in -a table to facilitate occasional invalidation checks. +Automatic principal provisioning can be enabled through OIDC integration. Cacheroach will request +OIDC credentials with an offline scope. Principals are periodically re-validated using the OIDC +refresh token. A whitelist of email domains is provided as part of cacheroach's configuration to +limit access to specified users. + +Sessions are exposed as signed [JWT tokens](https://jwt.io). Active sessions are maintained in a +table to facilitate occasional invalidation checks. The API surface area uses a [declarative model](./api/capabilities.proto) to implement ACL checks in a [centralized](./pkg/enforcer) manner. All access checks will have been performed by the time an diff --git a/api/auth.proto b/api/auth.proto deleted file mode 100644 index c4cf41c..0000000 --- a/api/auth.proto +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2021 The Cockroach Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -syntax = "proto3"; -package cacheroach.auth; -import "capabilities.proto"; -import "session.proto"; -import "token.proto"; -option go_package = "github.com/bobvawter/cacheroach/api/auth"; - -message LoginRequest { - string handle = 1; - string password = 2; -} - -message LoginResponse { - session.Session session = 1; - token.Token token = 2; -} - -service Auth { - // Login is a public method. - rpc Login (LoginRequest) returns (LoginResponse) { - option (capabilities.method_rule).auth_status = PUBLIC; - } -} \ No newline at end of file diff --git a/api/auth/auth.pb.go b/api/auth/auth.pb.go deleted file mode 100644 index fd232f0..0000000 --- a/api/auth/auth.pb.go +++ /dev/null @@ -1,265 +0,0 @@ -// Copyright 2021 The Cockroach Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.25.0 -// protoc v3.14.0 -// source: auth.proto - -package auth - -import ( - _ "github.com/bobvawter/cacheroach/api/capabilities" - session "github.com/bobvawter/cacheroach/api/session" - token "github.com/bobvawter/cacheroach/api/token" - proto "github.com/golang/protobuf/proto" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" -) - -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) -) - -// This is a compile-time assertion that a sufficiently up-to-date version -// of the legacy proto package is being used. -const _ = proto.ProtoPackageIsVersion4 - -type LoginRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Handle string `protobuf:"bytes,1,opt,name=handle,proto3" json:"handle,omitempty"` - Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` -} - -func (x *LoginRequest) Reset() { - *x = LoginRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_auth_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *LoginRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*LoginRequest) ProtoMessage() {} - -func (x *LoginRequest) ProtoReflect() protoreflect.Message { - mi := &file_auth_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use LoginRequest.ProtoReflect.Descriptor instead. -func (*LoginRequest) Descriptor() ([]byte, []int) { - return file_auth_proto_rawDescGZIP(), []int{0} -} - -func (x *LoginRequest) GetHandle() string { - if x != nil { - return x.Handle - } - return "" -} - -func (x *LoginRequest) GetPassword() string { - if x != nil { - return x.Password - } - return "" -} - -type LoginResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Session *session.Session `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` - Token *token.Token `protobuf:"bytes,2,opt,name=token,proto3" json:"token,omitempty"` -} - -func (x *LoginResponse) Reset() { - *x = LoginResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_auth_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *LoginResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*LoginResponse) ProtoMessage() {} - -func (x *LoginResponse) ProtoReflect() protoreflect.Message { - mi := &file_auth_proto_msgTypes[1] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use LoginResponse.ProtoReflect.Descriptor instead. -func (*LoginResponse) Descriptor() ([]byte, []int) { - return file_auth_proto_rawDescGZIP(), []int{1} -} - -func (x *LoginResponse) GetSession() *session.Session { - if x != nil { - return x.Session - } - return nil -} - -func (x *LoginResponse) GetToken() *token.Token { - if x != nil { - return x.Token - } - return nil -} - -var File_auth_proto protoreflect.FileDescriptor - -var file_auth_proto_rawDesc = []byte{ - 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x68, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0f, 0x63, 0x61, - 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x1a, 0x12, 0x63, - 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x1a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x1a, 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x42, 0x0a, - 0x0c, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, - 0x06, 0x68, 0x61, 0x6e, 0x64, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x68, - 0x61, 0x6e, 0x64, 0x6c, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, - 0x64, 0x22, 0x75, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x35, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, - 0x2e, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2d, 0x0a, 0x05, 0x74, 0x6f, 0x6b, - 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, - 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x2e, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x32, 0x56, 0x0a, 0x04, 0x41, 0x75, 0x74, 0x68, - 0x12, 0x4e, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1d, 0x2e, 0x63, 0x61, 0x63, 0x68, - 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x2e, 0x4c, 0x6f, 0x67, 0x69, - 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, - 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x06, 0x9a, 0xf5, 0x1a, 0x02, 0x30, 0x01, - 0x42, 0x2a, 0x5a, 0x28, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x62, - 0x6f, 0x62, 0x76, 0x61, 0x77, 0x74, 0x65, 0x72, 0x2f, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, - 0x61, 0x63, 0x68, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x62, 0x06, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x33, -} - -var ( - file_auth_proto_rawDescOnce sync.Once - file_auth_proto_rawDescData = file_auth_proto_rawDesc -) - -func file_auth_proto_rawDescGZIP() []byte { - file_auth_proto_rawDescOnce.Do(func() { - file_auth_proto_rawDescData = protoimpl.X.CompressGZIP(file_auth_proto_rawDescData) - }) - return file_auth_proto_rawDescData -} - -var file_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 2) -var file_auth_proto_goTypes = []interface{}{ - (*LoginRequest)(nil), // 0: cacheroach.auth.LoginRequest - (*LoginResponse)(nil), // 1: cacheroach.auth.LoginResponse - (*session.Session)(nil), // 2: cacheroach.session.Session - (*token.Token)(nil), // 3: cacheroach.token.Token -} -var file_auth_proto_depIdxs = []int32{ - 2, // 0: cacheroach.auth.LoginResponse.session:type_name -> cacheroach.session.Session - 3, // 1: cacheroach.auth.LoginResponse.token:type_name -> cacheroach.token.Token - 0, // 2: cacheroach.auth.Auth.Login:input_type -> cacheroach.auth.LoginRequest - 1, // 3: cacheroach.auth.Auth.Login:output_type -> cacheroach.auth.LoginResponse - 3, // [3:4] is the sub-list for method output_type - 2, // [2:3] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name -} - -func init() { file_auth_proto_init() } -func file_auth_proto_init() { - if File_auth_proto != nil { - return - } - if !protoimpl.UnsafeEnabled { - file_auth_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*LoginRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_auth_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*LoginResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_auth_proto_rawDesc, - NumEnums: 0, - NumMessages: 2, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_auth_proto_goTypes, - DependencyIndexes: file_auth_proto_depIdxs, - MessageInfos: file_auth_proto_msgTypes, - }.Build() - File_auth_proto = out.File - file_auth_proto_rawDesc = nil - file_auth_proto_goTypes = nil - file_auth_proto_depIdxs = nil -} diff --git a/api/auth/auth_grpc.pb.go b/api/auth/auth_grpc.pb.go deleted file mode 100644 index 78e39aa..0000000 --- a/api/auth/auth_grpc.pb.go +++ /dev/null @@ -1,99 +0,0 @@ -// Code generated by protoc-gen-go-grpc. DO NOT EDIT. - -package auth - -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. -const _ = grpc.SupportPackageIsVersion7 - -// AuthClient is the client API for Auth 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. -type AuthClient interface { - // Login is a public method. - Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) -} - -type authClient struct { - cc grpc.ClientConnInterface -} - -func NewAuthClient(cc grpc.ClientConnInterface) AuthClient { - return &authClient{cc} -} - -func (c *authClient) Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) { - out := new(LoginResponse) - err := c.cc.Invoke(ctx, "/cacheroach.auth.Auth/Login", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -// AuthServer is the server API for Auth service. -// All implementations must embed UnimplementedAuthServer -// for forward compatibility -type AuthServer interface { - // Login is a public method. - Login(context.Context, *LoginRequest) (*LoginResponse, error) - mustEmbedUnimplementedAuthServer() -} - -// UnimplementedAuthServer must be embedded to have forward compatible implementations. -type UnimplementedAuthServer struct { -} - -func (UnimplementedAuthServer) Login(context.Context, *LoginRequest) (*LoginResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Login not implemented") -} -func (UnimplementedAuthServer) mustEmbedUnimplementedAuthServer() {} - -// UnsafeAuthServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to AuthServer will -// result in compilation errors. -type UnsafeAuthServer interface { - mustEmbedUnimplementedAuthServer() -} - -func RegisterAuthServer(s grpc.ServiceRegistrar, srv AuthServer) { - s.RegisterService(&_Auth_serviceDesc, srv) -} - -func _Auth_Login_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(LoginRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(AuthServer).Login(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: "/cacheroach.auth.Auth/Login", - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(AuthServer).Login(ctx, req.(*LoginRequest)) - } - return interceptor(ctx, in, info, handler) -} - -var _Auth_serviceDesc = grpc.ServiceDesc{ - ServiceName: "cacheroach.auth.Auth", - HandlerType: (*AuthServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "Login", - Handler: _Auth_Login_Handler, - }, - }, - Streams: []grpc.StreamDesc{}, - Metadata: "auth.proto", -} diff --git a/api/principal.proto b/api/principal.proto index 62edae1..2be84ed 100644 --- a/api/principal.proto +++ b/api/principal.proto @@ -16,12 +16,27 @@ package cacheroach.principal; import "capabilities.proto"; import "google/protobuf/duration.proto"; import "google/protobuf/empty.proto"; +import "google/protobuf/timestamp.proto"; option go_package = "github.com/bobvawter/cacheroach/api/principal"; message ID { bytes data = 1; } +enum TokenStatus { + // The token has not (yet) been validated. + UNKNOWN = 0; + // The token should be considered valid until the refresh_after time. + VALID = 1; + // The token is being refreshed by another instance. It should be + // considered valid until the listed refresh time, at which point it + // should be refreshed again. + REFRESHING = 2; + // The token could not be revalidated and no further attempts should + // be made. + PERMANENT_FAILURE = 3; +} + message Principal { option (capabilities.msg_rule).or = { rule: {direction: RESPONSE} @@ -36,32 +51,54 @@ message Principal { ID ID = 1; string label = 2; int64 version = 3; - repeated string handles = 4 [ + // OIDC claims as provided by an authentication server. + bytes claims = 4 [ (capabilities.field_rule) = { message: "must have pii access" - may: { - capabilities: {pii: true} - scope: {on_principal: {field: 1}} + or : { + rule: { + and: { + rule: {direction: REQUEST} + rule : {auth_status: SUPER} + } + } + rule: { + and: { + rule: {direction: RESPONSE} + rule: {may: { + capabilities: {pii: true} + scope: {on_principal: {field: 1}} + }} + } + } } } ]; - - // Used internally, not exposed to clients. - string password_hash = 32 [ - (capabilities.field_rule) = { - message: "internal use only" - never: true - } + // If present, indicates that the principal represents all users whose + // email address are in the given domain. + string email_domain = 5; + string refresh_token = 66 [ + (capabilities.field_rule).never = true ]; - // A plain-text password which will be hashed by the server. - string password_set = 33 [ - (capabilities.field_rule) = { - message: "not returned to caller" - direction: REQUEST - } + google.protobuf.Timestamp refresh_after = 67 [ + (capabilities.field_rule).never = true + ]; + TokenStatus refresh_status = 68 [ + (capabilities.field_rule).never = true ]; } +message LoadRequest { + oneof Kind { + // Load a Principal based on ID. + ID ID = 1; + // Load a Principal by email address. + string email = 2; + // Load a domain-level Principal. + string email_domain = 3; + } +} + message WatchRequest { ID principal = 1; google.protobuf.Duration duration = 2; @@ -83,7 +120,7 @@ service Principals { rpc List (google.protobuf.Empty) returns (stream Principal) { option idempotency_level = NO_SIDE_EFFECTS; } - rpc Load (ID) returns (Principal) { + rpc Load (LoadRequest) returns (Principal) { option idempotency_level = NO_SIDE_EFFECTS; option (capabilities.method_rule).auth_status = LOGGED_IN; } diff --git a/api/principal/principal.go b/api/principal/principal.go index 5dddfc4..400a349 100644 --- a/api/principal/principal.go +++ b/api/principal/principal.go @@ -21,7 +21,6 @@ import ( "github.com/google/uuid" "github.com/pkg/errors" - "golang.org/x/crypto/bcrypt" ) // Unauthenticated is a well-known ID that represents an unauthenticated user. @@ -102,13 +101,3 @@ func (x *ID) Value() (driver.Value, error) { func (x *ID) Zero() bool { return len(x.GetData()) == 0 } - -// SetPassword updates PasswordHash based on a new, plain-text password. -func (x *Principal) SetPassword(pw string) error { - h, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) - if err != nil { - return err - } - x.PasswordHash = string(h) - return nil -} diff --git a/api/principal/principal.pb.go b/api/principal/principal.pb.go index f2c4220..f9babfd 100644 --- a/api/principal/principal.pb.go +++ b/api/principal/principal.pb.go @@ -26,6 +26,7 @@ import ( protoimpl "google.golang.org/protobuf/runtime/protoimpl" durationpb "google.golang.org/protobuf/types/known/durationpb" emptypb "google.golang.org/protobuf/types/known/emptypb" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" ) @@ -41,6 +42,65 @@ const ( // of the legacy proto package is being used. const _ = proto.ProtoPackageIsVersion4 +type TokenStatus int32 + +const ( + // The token has not (yet) been validated. + TokenStatus_UNKNOWN TokenStatus = 0 + // The token should be considered valid until the refresh_after time. + TokenStatus_VALID TokenStatus = 1 + // The token is being refreshed by another instance. It should be + // considered valid until the listed refresh time, at which point it + // should be refreshed again. + TokenStatus_REFRESHING TokenStatus = 2 + // The token could not be revalidated and no further attempts should + // be made. + TokenStatus_PERMANENT_FAILURE TokenStatus = 3 +) + +// Enum value maps for TokenStatus. +var ( + TokenStatus_name = map[int32]string{ + 0: "UNKNOWN", + 1: "VALID", + 2: "REFRESHING", + 3: "PERMANENT_FAILURE", + } + TokenStatus_value = map[string]int32{ + "UNKNOWN": 0, + "VALID": 1, + "REFRESHING": 2, + "PERMANENT_FAILURE": 3, + } +) + +func (x TokenStatus) Enum() *TokenStatus { + p := new(TokenStatus) + *p = x + return p +} + +func (x TokenStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TokenStatus) Descriptor() protoreflect.EnumDescriptor { + return file_principal_proto_enumTypes[0].Descriptor() +} + +func (TokenStatus) Type() protoreflect.EnumType { + return &file_principal_proto_enumTypes[0] +} + +func (x TokenStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TokenStatus.Descriptor instead. +func (TokenStatus) EnumDescriptor() ([]byte, []int) { + return file_principal_proto_rawDescGZIP(), []int{0} +} + type ID struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -93,14 +153,17 @@ type Principal struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - ID *ID `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` - Label string `protobuf:"bytes,2,opt,name=label,proto3" json:"label,omitempty"` - Version int64 `protobuf:"varint,3,opt,name=version,proto3" json:"version,omitempty"` - Handles []string `protobuf:"bytes,4,rep,name=handles,proto3" json:"handles,omitempty"` - // Used internally, not exposed to clients. - PasswordHash string `protobuf:"bytes,32,opt,name=password_hash,json=passwordHash,proto3" json:"password_hash,omitempty"` - // A plain-text password which will be hashed by the server. - PasswordSet string `protobuf:"bytes,33,opt,name=password_set,json=passwordSet,proto3" json:"password_set,omitempty"` + ID *ID `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` + Label string `protobuf:"bytes,2,opt,name=label,proto3" json:"label,omitempty"` + Version int64 `protobuf:"varint,3,opt,name=version,proto3" json:"version,omitempty"` + // OIDC claims as provided by an authentication server. + Claims []byte `protobuf:"bytes,4,opt,name=claims,proto3" json:"claims,omitempty"` + // If present, indicates that the principal represents all users whose + // email address are in the given domain. + EmailDomain string `protobuf:"bytes,5,opt,name=email_domain,json=emailDomain,proto3" json:"email_domain,omitempty"` + RefreshToken string `protobuf:"bytes,66,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"` + RefreshAfter *timestamppb.Timestamp `protobuf:"bytes,67,opt,name=refresh_after,json=refreshAfter,proto3" json:"refresh_after,omitempty"` + RefreshStatus TokenStatus `protobuf:"varint,68,opt,name=refresh_status,json=refreshStatus,proto3,enum=cacheroach.principal.TokenStatus" json:"refresh_status,omitempty"` } func (x *Principal) Reset() { @@ -156,27 +219,138 @@ func (x *Principal) GetVersion() int64 { return 0 } -func (x *Principal) GetHandles() []string { +func (x *Principal) GetClaims() []byte { if x != nil { - return x.Handles + return x.Claims } return nil } -func (x *Principal) GetPasswordHash() string { +func (x *Principal) GetEmailDomain() string { + if x != nil { + return x.EmailDomain + } + return "" +} + +func (x *Principal) GetRefreshToken() string { if x != nil { - return x.PasswordHash + return x.RefreshToken } return "" } -func (x *Principal) GetPasswordSet() string { +func (x *Principal) GetRefreshAfter() *timestamppb.Timestamp { + if x != nil { + return x.RefreshAfter + } + return nil +} + +func (x *Principal) GetRefreshStatus() TokenStatus { if x != nil { - return x.PasswordSet + return x.RefreshStatus + } + return TokenStatus_UNKNOWN +} + +type LoadRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Kind: + // *LoadRequest_ID + // *LoadRequest_Email + // *LoadRequest_EmailDomain + Kind isLoadRequest_Kind `protobuf_oneof:"Kind"` +} + +func (x *LoadRequest) Reset() { + *x = LoadRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_principal_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LoadRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LoadRequest) ProtoMessage() {} + +func (x *LoadRequest) ProtoReflect() protoreflect.Message { + mi := &file_principal_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LoadRequest.ProtoReflect.Descriptor instead. +func (*LoadRequest) Descriptor() ([]byte, []int) { + return file_principal_proto_rawDescGZIP(), []int{2} +} + +func (m *LoadRequest) GetKind() isLoadRequest_Kind { + if m != nil { + return m.Kind + } + return nil +} + +func (x *LoadRequest) GetID() *ID { + if x, ok := x.GetKind().(*LoadRequest_ID); ok { + return x.ID + } + return nil +} + +func (x *LoadRequest) GetEmail() string { + if x, ok := x.GetKind().(*LoadRequest_Email); ok { + return x.Email + } + return "" +} + +func (x *LoadRequest) GetEmailDomain() string { + if x, ok := x.GetKind().(*LoadRequest_EmailDomain); ok { + return x.EmailDomain } return "" } +type isLoadRequest_Kind interface { + isLoadRequest_Kind() +} + +type LoadRequest_ID struct { + // Load a Principal based on ID. + ID *ID `protobuf:"bytes,1,opt,name=ID,proto3,oneof"` +} + +type LoadRequest_Email struct { + // Load a Principal by email address. + Email string `protobuf:"bytes,2,opt,name=email,proto3,oneof"` +} + +type LoadRequest_EmailDomain struct { + // Load a domain-level Principal. + EmailDomain string `protobuf:"bytes,3,opt,name=email_domain,json=emailDomain,proto3,oneof"` +} + +func (*LoadRequest_ID) isLoadRequest_Kind() {} + +func (*LoadRequest_Email) isLoadRequest_Kind() {} + +func (*LoadRequest_EmailDomain) isLoadRequest_Kind() {} + type WatchRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -189,7 +363,7 @@ type WatchRequest struct { func (x *WatchRequest) Reset() { *x = WatchRequest{} if protoimpl.UnsafeEnabled { - mi := &file_principal_proto_msgTypes[2] + mi := &file_principal_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -202,7 +376,7 @@ func (x *WatchRequest) String() string { func (*WatchRequest) ProtoMessage() {} func (x *WatchRequest) ProtoReflect() protoreflect.Message { - mi := &file_principal_proto_msgTypes[2] + mi := &file_principal_proto_msgTypes[3] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -215,7 +389,7 @@ func (x *WatchRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchRequest.ProtoReflect.Descriptor instead. func (*WatchRequest) Descriptor() ([]byte, []int) { - return file_principal_proto_rawDescGZIP(), []int{2} + return file_principal_proto_rawDescGZIP(), []int{3} } func (x *WatchRequest) GetPrincipal() *ID { @@ -244,7 +418,7 @@ type EnsureRequest struct { func (x *EnsureRequest) Reset() { *x = EnsureRequest{} if protoimpl.UnsafeEnabled { - mi := &file_principal_proto_msgTypes[3] + mi := &file_principal_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -257,7 +431,7 @@ func (x *EnsureRequest) String() string { func (*EnsureRequest) ProtoMessage() {} func (x *EnsureRequest) ProtoReflect() protoreflect.Message { - mi := &file_principal_proto_msgTypes[3] + mi := &file_principal_proto_msgTypes[4] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -270,7 +444,7 @@ func (x *EnsureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EnsureRequest.ProtoReflect.Descriptor instead. func (*EnsureRequest) Descriptor() ([]byte, []int) { - return file_principal_proto_rawDescGZIP(), []int{3} + return file_principal_proto_rawDescGZIP(), []int{4} } func (x *EnsureRequest) GetPrincipal() *Principal { @@ -298,7 +472,7 @@ type EnsureResponse struct { func (x *EnsureResponse) Reset() { *x = EnsureResponse{} if protoimpl.UnsafeEnabled { - mi := &file_principal_proto_msgTypes[4] + mi := &file_principal_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -311,7 +485,7 @@ func (x *EnsureResponse) String() string { func (*EnsureResponse) ProtoMessage() {} func (x *EnsureResponse) ProtoReflect() protoreflect.Message { - mi := &file_principal_proto_msgTypes[4] + mi := &file_principal_proto_msgTypes[5] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -324,7 +498,7 @@ func (x *EnsureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EnsureResponse.ProtoReflect.Descriptor instead. func (*EnsureResponse) Descriptor() ([]byte, []int) { - return file_principal_proto_rawDescGZIP(), []int{4} + return file_principal_proto_rawDescGZIP(), []int{5} } func (x *EnsureResponse) GetPrincipal() *Principal { @@ -344,76 +518,99 @@ var file_principal_proto_rawDesc = []byte{ 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, - 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x18, 0x0a, 0x02, 0x49, 0x44, 0x12, 0x12, - 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, - 0x74, 0x61, 0x22, 0xe8, 0x02, 0x0a, 0x09, 0x50, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, - 0x12, 0x28, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63, - 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, - 0x70, 0x61, 0x6c, 0x2e, 0x49, 0x44, 0x52, 0x02, 0x49, 0x44, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61, - 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, - 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x03, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x40, 0x0a, 0x07, 0x68, 0x61, - 0x6e, 0x64, 0x6c, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x42, 0x26, 0x9a, 0xf5, 0x1a, - 0x22, 0x3a, 0x0a, 0x12, 0x02, 0x20, 0x01, 0x22, 0x04, 0x12, 0x02, 0x10, 0x01, 0x7a, 0x14, 0x6d, - 0x75, 0x73, 0x74, 0x20, 0x68, 0x61, 0x76, 0x65, 0x20, 0x70, 0x69, 0x69, 0x20, 0x61, 0x63, 0x63, - 0x65, 0x73, 0x73, 0x52, 0x07, 0x68, 0x61, 0x6e, 0x64, 0x6c, 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x0d, - 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x20, 0x20, - 0x01, 0x28, 0x09, 0x42, 0x19, 0x9a, 0xf5, 0x1a, 0x15, 0x18, 0x01, 0x7a, 0x11, 0x69, 0x6e, 0x74, - 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x20, 0x75, 0x73, 0x65, 0x20, 0x6f, 0x6e, 0x6c, 0x79, 0x52, 0x0c, - 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x48, 0x61, 0x73, 0x68, 0x12, 0x41, 0x0a, 0x0c, - 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x5f, 0x73, 0x65, 0x74, 0x18, 0x21, 0x20, 0x01, - 0x28, 0x09, 0x42, 0x1e, 0x9a, 0xf5, 0x1a, 0x1a, 0x40, 0x01, 0x7a, 0x16, 0x6e, 0x6f, 0x74, 0x20, - 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x65, 0x64, 0x20, 0x74, 0x6f, 0x20, 0x63, 0x61, 0x6c, 0x6c, - 0x65, 0x72, 0x52, 0x0b, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x53, 0x65, 0x74, 0x3a, - 0x3c, 0x9a, 0xf5, 0x1a, 0x38, 0x2a, 0x36, 0x0a, 0x02, 0x40, 0x02, 0x0a, 0x30, 0x3a, 0x0a, 0x12, - 0x02, 0x08, 0x01, 0x22, 0x04, 0x12, 0x02, 0x10, 0x01, 0x7a, 0x22, 0x6d, 0x75, 0x73, 0x74, 0x20, - 0x62, 0x65, 0x20, 0x61, 0x62, 0x6c, 0x65, 0x20, 0x74, 0x6f, 0x20, 0x72, 0x65, 0x61, 0x64, 0x20, - 0x74, 0x68, 0x65, 0x20, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x22, 0x7d, 0x0a, - 0x0c, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x36, 0x0a, - 0x09, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x18, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, - 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x2e, 0x49, 0x44, 0x52, 0x09, 0x70, 0x72, 0x69, 0x6e, - 0x63, 0x69, 0x70, 0x61, 0x6c, 0x12, 0x35, 0x0a, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x66, 0x0a, 0x0d, - 0x45, 0x6e, 0x73, 0x75, 0x72, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x3d, 0x0a, - 0x09, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x1f, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, - 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x2e, 0x50, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, - 0x6c, 0x52, 0x09, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x12, 0x16, 0x0a, 0x06, - 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x64, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x22, 0x4f, 0x0a, 0x0e, 0x45, 0x6e, 0x73, 0x75, 0x72, 0x65, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x09, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, - 0x70, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x63, 0x61, 0x63, 0x68, - 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, - 0x2e, 0x50, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x52, 0x09, 0x70, 0x72, 0x69, 0x6e, - 0x63, 0x69, 0x70, 0x61, 0x6c, 0x32, 0xd1, 0x02, 0x0a, 0x0a, 0x50, 0x72, 0x69, 0x6e, 0x63, 0x69, - 0x70, 0x61, 0x6c, 0x73, 0x12, 0x58, 0x0a, 0x06, 0x45, 0x6e, 0x73, 0x75, 0x72, 0x65, 0x12, 0x23, - 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x69, 0x6e, - 0x63, 0x69, 0x70, 0x61, 0x6c, 0x2e, 0x45, 0x6e, 0x73, 0x75, 0x72, 0x65, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, - 0x2e, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x2e, 0x45, 0x6e, 0x73, 0x75, 0x72, - 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x03, 0x90, 0x02, 0x02, 0x12, 0x46, - 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1f, + 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x18, 0x0a, 0x02, 0x49, 0x44, 0x12, + 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, + 0x61, 0x74, 0x61, 0x22, 0xe6, 0x03, 0x0a, 0x09, 0x50, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, + 0x6c, 0x12, 0x28, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, + 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x69, 0x6e, 0x63, + 0x69, 0x70, 0x61, 0x6c, 0x2e, 0x49, 0x44, 0x52, 0x02, 0x49, 0x44, 0x12, 0x14, 0x0a, 0x05, 0x6c, + 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, + 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x56, 0x0a, 0x06, 0x63, + 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x42, 0x3e, 0x9a, 0xf5, 0x1a, + 0x3a, 0x2a, 0x22, 0x0a, 0x0a, 0x22, 0x08, 0x0a, 0x02, 0x40, 0x01, 0x0a, 0x02, 0x30, 0x02, 0x0a, + 0x14, 0x22, 0x12, 0x0a, 0x02, 0x40, 0x02, 0x0a, 0x0c, 0x3a, 0x0a, 0x12, 0x02, 0x20, 0x01, 0x22, + 0x04, 0x12, 0x02, 0x10, 0x01, 0x7a, 0x14, 0x6d, 0x75, 0x73, 0x74, 0x20, 0x68, 0x61, 0x76, 0x65, + 0x20, 0x70, 0x69, 0x69, 0x20, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x06, 0x63, 0x6c, 0x61, + 0x69, 0x6d, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x64, 0x6f, 0x6d, + 0x61, 0x69, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x65, 0x6d, 0x61, 0x69, 0x6c, + 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2b, 0x0a, 0x0d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, + 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x42, 0x20, 0x01, 0x28, 0x09, 0x42, 0x06, 0x9a, + 0xf5, 0x1a, 0x02, 0x18, 0x01, 0x52, 0x0c, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x12, 0x47, 0x0a, 0x0d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x61, + 0x66, 0x74, 0x65, 0x72, 0x18, 0x43, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, + 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x42, 0x06, 0x9a, 0xf5, 0x1a, 0x02, 0x18, 0x01, 0x52, 0x0c, + 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x41, 0x66, 0x74, 0x65, 0x72, 0x12, 0x50, 0x0a, 0x0e, + 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x44, + 0x20, 0x01, 0x28, 0x0e, 0x32, 0x21, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, + 0x68, 0x2e, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x2e, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x42, 0x06, 0x9a, 0xf5, 0x1a, 0x02, 0x18, 0x01, 0x52, + 0x0d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x3a, 0x3c, + 0x9a, 0xf5, 0x1a, 0x38, 0x2a, 0x36, 0x0a, 0x02, 0x40, 0x02, 0x0a, 0x30, 0x3a, 0x0a, 0x12, 0x02, + 0x08, 0x01, 0x22, 0x04, 0x12, 0x02, 0x10, 0x01, 0x7a, 0x22, 0x6d, 0x75, 0x73, 0x74, 0x20, 0x62, + 0x65, 0x20, 0x61, 0x62, 0x6c, 0x65, 0x20, 0x74, 0x6f, 0x20, 0x72, 0x65, 0x61, 0x64, 0x20, 0x74, + 0x68, 0x65, 0x20, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x22, 0x7e, 0x0a, 0x0b, + 0x4c, 0x6f, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x02, 0x49, + 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, + 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x2e, 0x49, + 0x44, 0x48, 0x00, 0x52, 0x02, 0x49, 0x44, 0x12, 0x16, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, + 0x23, 0x0a, 0x0c, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0b, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x44, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x42, 0x06, 0x0a, 0x04, 0x4b, 0x69, 0x6e, 0x64, 0x22, 0x7d, 0x0a, 0x0c, + 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x36, 0x0a, 0x09, + 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x18, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x69, + 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x2e, 0x49, 0x44, 0x52, 0x09, 0x70, 0x72, 0x69, 0x6e, 0x63, + 0x69, 0x70, 0x61, 0x6c, 0x12, 0x35, 0x0a, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x66, 0x0a, 0x0d, 0x45, + 0x6e, 0x73, 0x75, 0x72, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x3d, 0x0a, 0x09, + 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1f, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x69, + 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x2e, 0x50, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, + 0x52, 0x09, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x64, 0x65, 0x6c, + 0x65, 0x74, 0x65, 0x22, 0x4f, 0x0a, 0x0e, 0x45, 0x6e, 0x73, 0x75, 0x72, 0x65, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x09, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, + 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, + 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x2e, + 0x50, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x52, 0x09, 0x70, 0x72, 0x69, 0x6e, 0x63, + 0x69, 0x70, 0x61, 0x6c, 0x2a, 0x4c, 0x0a, 0x0b, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, + 0x12, 0x09, 0x0a, 0x05, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x52, + 0x45, 0x46, 0x52, 0x45, 0x53, 0x48, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x15, 0x0a, 0x11, 0x50, + 0x45, 0x52, 0x4d, 0x41, 0x4e, 0x45, 0x4e, 0x54, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x55, 0x52, 0x45, + 0x10, 0x03, 0x32, 0xda, 0x02, 0x0a, 0x0a, 0x50, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, + 0x73, 0x12, 0x58, 0x0a, 0x06, 0x45, 0x6e, 0x73, 0x75, 0x72, 0x65, 0x12, 0x23, 0x2e, 0x63, 0x61, + 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, + 0x61, 0x6c, 0x2e, 0x45, 0x6e, 0x73, 0x75, 0x72, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x24, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, + 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x2e, 0x45, 0x6e, 0x73, 0x75, 0x72, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x03, 0x90, 0x02, 0x02, 0x12, 0x46, 0x0a, 0x04, 0x4c, + 0x69, 0x73, 0x74, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1f, 0x2e, 0x63, 0x61, + 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, + 0x61, 0x6c, 0x2e, 0x50, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x22, 0x03, 0x90, 0x02, + 0x01, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x04, 0x4c, 0x6f, 0x61, 0x64, 0x12, 0x21, 0x2e, 0x63, 0x61, + 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, + 0x61, 0x6c, 0x2e, 0x4c, 0x6f, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x2e, 0x50, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x22, - 0x03, 0x90, 0x02, 0x01, 0x30, 0x01, 0x12, 0x4c, 0x0a, 0x04, 0x4c, 0x6f, 0x61, 0x64, 0x12, 0x18, - 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x69, 0x6e, - 0x63, 0x69, 0x70, 0x61, 0x6c, 0x2e, 0x49, 0x44, 0x1a, 0x1f, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, - 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x2e, - 0x50, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x22, 0x09, 0x90, 0x02, 0x01, 0x9a, 0xf5, - 0x1a, 0x02, 0x30, 0x00, 0x12, 0x53, 0x0a, 0x05, 0x57, 0x61, 0x74, 0x63, 0x68, 0x12, 0x22, 0x2e, - 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x69, 0x6e, 0x63, - 0x69, 0x70, 0x61, 0x6c, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1f, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, - 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x2e, 0x50, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, - 0x61, 0x6c, 0x22, 0x03, 0x90, 0x02, 0x01, 0x30, 0x01, 0x42, 0x2f, 0x5a, 0x2d, 0x67, 0x69, 0x74, - 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x62, 0x6f, 0x62, 0x76, 0x61, 0x77, 0x74, 0x65, - 0x72, 0x2f, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, 0x2f, 0x61, 0x70, 0x69, - 0x2f, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 0x09, 0x90, 0x02, 0x01, 0x9a, 0xf5, 0x1a, 0x02, 0x30, 0x00, 0x12, 0x53, 0x0a, 0x05, 0x57, 0x61, + 0x74, 0x63, 0x68, 0x12, 0x22, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, 0x63, 0x68, + 0x2e, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, + 0x6f, 0x61, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x2e, 0x50, + 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, 0x22, 0x03, 0x90, 0x02, 0x01, 0x30, 0x01, 0x42, + 0x2f, 0x5a, 0x2d, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x62, 0x6f, + 0x62, 0x76, 0x61, 0x77, 0x74, 0x65, 0x72, 0x2f, 0x63, 0x61, 0x63, 0x68, 0x65, 0x72, 0x6f, 0x61, + 0x63, 0x68, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x69, 0x6e, 0x63, 0x69, 0x70, 0x61, 0x6c, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -428,35 +625,42 @@ func file_principal_proto_rawDescGZIP() []byte { return file_principal_proto_rawDescData } -var file_principal_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_principal_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_principal_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_principal_proto_goTypes = []interface{}{ - (*ID)(nil), // 0: cacheroach.principal.ID - (*Principal)(nil), // 1: cacheroach.principal.Principal - (*WatchRequest)(nil), // 2: cacheroach.principal.WatchRequest - (*EnsureRequest)(nil), // 3: cacheroach.principal.EnsureRequest - (*EnsureResponse)(nil), // 4: cacheroach.principal.EnsureResponse - (*durationpb.Duration)(nil), // 5: google.protobuf.Duration - (*emptypb.Empty)(nil), // 6: google.protobuf.Empty + (TokenStatus)(0), // 0: cacheroach.principal.TokenStatus + (*ID)(nil), // 1: cacheroach.principal.ID + (*Principal)(nil), // 2: cacheroach.principal.Principal + (*LoadRequest)(nil), // 3: cacheroach.principal.LoadRequest + (*WatchRequest)(nil), // 4: cacheroach.principal.WatchRequest + (*EnsureRequest)(nil), // 5: cacheroach.principal.EnsureRequest + (*EnsureResponse)(nil), // 6: cacheroach.principal.EnsureResponse + (*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 8: google.protobuf.Duration + (*emptypb.Empty)(nil), // 9: google.protobuf.Empty } var file_principal_proto_depIdxs = []int32{ - 0, // 0: cacheroach.principal.Principal.ID:type_name -> cacheroach.principal.ID - 0, // 1: cacheroach.principal.WatchRequest.principal:type_name -> cacheroach.principal.ID - 5, // 2: cacheroach.principal.WatchRequest.duration:type_name -> google.protobuf.Duration - 1, // 3: cacheroach.principal.EnsureRequest.principal:type_name -> cacheroach.principal.Principal - 1, // 4: cacheroach.principal.EnsureResponse.principal:type_name -> cacheroach.principal.Principal - 3, // 5: cacheroach.principal.Principals.Ensure:input_type -> cacheroach.principal.EnsureRequest - 6, // 6: cacheroach.principal.Principals.List:input_type -> google.protobuf.Empty - 0, // 7: cacheroach.principal.Principals.Load:input_type -> cacheroach.principal.ID - 2, // 8: cacheroach.principal.Principals.Watch:input_type -> cacheroach.principal.WatchRequest - 4, // 9: cacheroach.principal.Principals.Ensure:output_type -> cacheroach.principal.EnsureResponse - 1, // 10: cacheroach.principal.Principals.List:output_type -> cacheroach.principal.Principal - 1, // 11: cacheroach.principal.Principals.Load:output_type -> cacheroach.principal.Principal - 1, // 12: cacheroach.principal.Principals.Watch:output_type -> cacheroach.principal.Principal - 9, // [9:13] is the sub-list for method output_type - 5, // [5:9] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name + 1, // 0: cacheroach.principal.Principal.ID:type_name -> cacheroach.principal.ID + 7, // 1: cacheroach.principal.Principal.refresh_after:type_name -> google.protobuf.Timestamp + 0, // 2: cacheroach.principal.Principal.refresh_status:type_name -> cacheroach.principal.TokenStatus + 1, // 3: cacheroach.principal.LoadRequest.ID:type_name -> cacheroach.principal.ID + 1, // 4: cacheroach.principal.WatchRequest.principal:type_name -> cacheroach.principal.ID + 8, // 5: cacheroach.principal.WatchRequest.duration:type_name -> google.protobuf.Duration + 2, // 6: cacheroach.principal.EnsureRequest.principal:type_name -> cacheroach.principal.Principal + 2, // 7: cacheroach.principal.EnsureResponse.principal:type_name -> cacheroach.principal.Principal + 5, // 8: cacheroach.principal.Principals.Ensure:input_type -> cacheroach.principal.EnsureRequest + 9, // 9: cacheroach.principal.Principals.List:input_type -> google.protobuf.Empty + 3, // 10: cacheroach.principal.Principals.Load:input_type -> cacheroach.principal.LoadRequest + 4, // 11: cacheroach.principal.Principals.Watch:input_type -> cacheroach.principal.WatchRequest + 6, // 12: cacheroach.principal.Principals.Ensure:output_type -> cacheroach.principal.EnsureResponse + 2, // 13: cacheroach.principal.Principals.List:output_type -> cacheroach.principal.Principal + 2, // 14: cacheroach.principal.Principals.Load:output_type -> cacheroach.principal.Principal + 2, // 15: cacheroach.principal.Principals.Watch:output_type -> cacheroach.principal.Principal + 12, // [12:16] is the sub-list for method output_type + 8, // [8:12] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name } func init() { file_principal_proto_init() } @@ -490,7 +694,7 @@ func file_principal_proto_init() { } } file_principal_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*WatchRequest); i { + switch v := v.(*LoadRequest); i { case 0: return &v.state case 1: @@ -502,7 +706,7 @@ func file_principal_proto_init() { } } file_principal_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*EnsureRequest); i { + switch v := v.(*WatchRequest); i { case 0: return &v.state case 1: @@ -514,6 +718,18 @@ func file_principal_proto_init() { } } file_principal_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*EnsureRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_principal_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*EnsureResponse); i { case 0: return &v.state @@ -526,18 +742,24 @@ func file_principal_proto_init() { } } } + file_principal_proto_msgTypes[2].OneofWrappers = []interface{}{ + (*LoadRequest_ID)(nil), + (*LoadRequest_Email)(nil), + (*LoadRequest_EmailDomain)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_principal_proto_rawDesc, - NumEnums: 0, - NumMessages: 5, + NumEnums: 1, + NumMessages: 6, NumExtensions: 0, NumServices: 1, }, GoTypes: file_principal_proto_goTypes, DependencyIndexes: file_principal_proto_depIdxs, + EnumInfos: file_principal_proto_enumTypes, MessageInfos: file_principal_proto_msgTypes, }.Build() File_principal_proto = out.File diff --git a/api/principal/principal_grpc.pb.go b/api/principal/principal_grpc.pb.go index 3c028a6..b7ea8bd 100644 --- a/api/principal/principal_grpc.pb.go +++ b/api/principal/principal_grpc.pb.go @@ -20,7 +20,7 @@ const _ = grpc.SupportPackageIsVersion7 type PrincipalsClient interface { Ensure(ctx context.Context, in *EnsureRequest, opts ...grpc.CallOption) (*EnsureResponse, error) List(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (Principals_ListClient, error) - Load(ctx context.Context, in *ID, opts ...grpc.CallOption) (*Principal, error) + Load(ctx context.Context, in *LoadRequest, opts ...grpc.CallOption) (*Principal, error) Watch(ctx context.Context, in *WatchRequest, opts ...grpc.CallOption) (Principals_WatchClient, error) } @@ -73,7 +73,7 @@ func (x *principalsListClient) Recv() (*Principal, error) { return m, nil } -func (c *principalsClient) Load(ctx context.Context, in *ID, opts ...grpc.CallOption) (*Principal, error) { +func (c *principalsClient) Load(ctx context.Context, in *LoadRequest, opts ...grpc.CallOption) (*Principal, error) { out := new(Principal) err := c.cc.Invoke(ctx, "/cacheroach.principal.Principals/Load", in, out, opts...) if err != nil { @@ -120,7 +120,7 @@ func (x *principalsWatchClient) Recv() (*Principal, error) { type PrincipalsServer interface { Ensure(context.Context, *EnsureRequest) (*EnsureResponse, error) List(*emptypb.Empty, Principals_ListServer) error - Load(context.Context, *ID) (*Principal, error) + Load(context.Context, *LoadRequest) (*Principal, error) Watch(*WatchRequest, Principals_WatchServer) error mustEmbedUnimplementedPrincipalsServer() } @@ -135,7 +135,7 @@ func (UnimplementedPrincipalsServer) Ensure(context.Context, *EnsureRequest) (*E func (UnimplementedPrincipalsServer) List(*emptypb.Empty, Principals_ListServer) error { return status.Errorf(codes.Unimplemented, "method List not implemented") } -func (UnimplementedPrincipalsServer) Load(context.Context, *ID) (*Principal, error) { +func (UnimplementedPrincipalsServer) Load(context.Context, *LoadRequest) (*Principal, error) { return nil, status.Errorf(codes.Unimplemented, "method Load not implemented") } func (UnimplementedPrincipalsServer) Watch(*WatchRequest, Principals_WatchServer) error { @@ -194,7 +194,7 @@ func (x *principalsListServer) Send(m *Principal) error { } func _Principals_Load_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ID) + in := new(LoadRequest) if err := dec(in); err != nil { return nil, err } @@ -206,7 +206,7 @@ func _Principals_Load_Handler(srv interface{}, ctx context.Context, dec func(int FullMethod: "/cacheroach.principal.Principals/Load", } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(PrincipalsServer).Load(ctx, req.(*ID)) + return srv.(PrincipalsServer).Load(ctx, req.(*LoadRequest)) } return interceptor(ctx, in, info, handler) } diff --git a/doc/cacheroach_auth.md b/doc/cacheroach_auth.md index 65ba235..8ec26c2 100644 --- a/doc/cacheroach_auth.md +++ b/doc/cacheroach_auth.md @@ -18,7 +18,7 @@ authentication services ### SEE ALSO * [cacheroach](cacheroach.md) - cacheroach is a file storage service built on CockroachDB -* [cacheroach auth login](cacheroach_auth_login.md) - log into a cacheroach server +* [cacheroach auth login](cacheroach_auth_login.md) - Log in via OIDC authentication * [cacheroach auth logout](cacheroach_auth_logout.md) - destroy authentication * [cacheroach auth set](cacheroach_auth_set.md) - log in using an authentication token * [cacheroach auth whoami](cacheroach_auth_whoami.md) - show the current principal diff --git a/doc/cacheroach_auth_login.md b/doc/cacheroach_auth_login.md index 3b83012..fab0c7e 100644 --- a/doc/cacheroach_auth_login.md +++ b/doc/cacheroach_auth_login.md @@ -1,13 +1,9 @@ ## cacheroach auth login -log into a cacheroach server - -### Synopsis - -if a password is not specified, it will be securely prompted for +Log in via OIDC authentication ``` -cacheroach auth login https://username[:password]@cacheroach.server/ [flags] +cacheroach auth login https://cacheroach.server [flags] ``` ### Options diff --git a/doc/cacheroach_bootstrap.md b/doc/cacheroach_bootstrap.md index 59e1d8a..aac1a85 100644 --- a/doc/cacheroach_bootstrap.md +++ b/doc/cacheroach_bootstrap.md @@ -7,7 +7,7 @@ create a super-user principal using the server's HMAC key This command should be used to create an initial user on a newly-created cacheroach installation. It requires access to the server's HMAC key that is used to sign tokens. The resulting session will have superuser access; the resulting configuration file should be treated with the same security as the key. ``` -cacheroach bootstrap [flags] https://username[:password]@cacheroach.server/ +cacheroach bootstrap [flags] https://cacheroach.server/ ``` ### Options diff --git a/doc/cacheroach_principal_create.md b/doc/cacheroach_principal_create.md index 2130496..d81842d 100644 --- a/doc/cacheroach_principal_create.md +++ b/doc/cacheroach_principal_create.md @@ -9,10 +9,9 @@ cacheroach principal create [flags] ### Options ``` - -h, --help help for create - --label string set the principal's label (defaults to username) - -o, --out string write a new configuration file, defaults to username.cfg - --password string set a password when creating the principal + --emailDomain string create a unique principal that represents all principals with an email address in the given domain + -h, --help help for create + -o, --out string write a new configuration file, defaults to username.cfg ``` ### Options inherited from parent commands diff --git a/doc/cacheroach_start.md b/doc/cacheroach_start.md index 5a58992..97efb30 100644 --- a/doc/cacheroach_start.md +++ b/doc/cacheroach_start.md @@ -9,6 +9,7 @@ cacheroach start [flags] ### Options ``` + --assumeSecure set this if you have a TLS load-balancer connecting to cacheroach over an unencrypted connection --bindAddr string the local IP and port to bind to (default ":0") --cacheDir string persistent cache location --cacheDiskSpace int the size (in megabytes) of the persistent cache (default 1024) @@ -21,6 +22,10 @@ cacheroach start [flags] --gracePeriod duration the grace period for draining connections (default 10s) -h, --help help for start --key string a file that contains a private key + --oidcClientID string the OIDC client ID + --oidcClientSecret string the OIDC client secret + --oidcDomains strings acceptable user email domains + --oidcIssuer string the OIDC discovery base URL --purgeDuration duration the length of time for which deleted data should be retained; set to 0 to disable (default 168h0m0s) --purgeLimit int the deletion batch size to use when purging old data; set to 0 to disable (default 1000) --readAmplificationBackoff int slow chunk insertions if the CockroachDB cluster's read amplification rises above this (default 10) diff --git a/doc/hero.png b/doc/hero.png index 1006f38..f5ca0f0 100644 Binary files a/doc/hero.png and b/doc/hero.png differ diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml index d4782e6..8758c84 100644 --- a/docker-compose.quickstart.yml +++ b/docker-compose.quickstart.yml @@ -31,8 +31,6 @@ services: done " cacheroach: - # build: - # context: . image: bobvawter/cacheroach:latest depends_on: - cockroachdb diff --git a/go.mod b/go.mod index b74a634..5b917df 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/allegro/bigcache/v3 v3.0.0 github.com/blushft/go-diagrams v0.0.0-20201006005127-c78c821223d9 github.com/bobvawter/latch v1.0.0 + github.com/coreos/go-oidc/v3 v3.0.0 github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 github.com/gofrs/uuid v3.3.0+incompatible // indirect github.com/golang/protobuf v1.4.3 @@ -29,7 +30,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.6.1 github.com/withmandala/go-log v0.1.0 // indirect - golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad + golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad // indirect golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 golang.org/x/mod v0.4.1 // indirect golang.org/x/net v0.0.0-20210119194325-5f4716e94777 diff --git a/go.sum b/go.sum index 3504d26..099066f 100644 --- a/go.sum +++ b/go.sum @@ -100,6 +100,8 @@ github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:z github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-oidc/v3 v3.0.0 h1:/mAA0XMgYJw2Uqm7WKGCsKnjitE/+A0FFbOmiRJm7LQ= +github.com/coreos/go-oidc/v3 v3.0.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -648,6 +650,7 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= @@ -932,6 +935,8 @@ gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= +gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/toqueteos/substring.v1 v1.0.2 h1:urLqCeMm6x/eTuQa1oZerNw8N1KNOIp5hD5kGL7lFsE= gopkg.in/toqueteos/substring.v1 v1.0.2/go.mod h1:Eb2Z1UYehlVK8LYW2WBVR2rwbujsz3aX8XDrM1vbNew= diff --git a/main.go b/main.go index 01d658d..8604380 100644 --- a/main.go +++ b/main.go @@ -16,7 +16,6 @@ package main import ( "context" "os" - "os/signal" "syscall" diff --git a/pkg/bootstrap/bootstrap.go b/pkg/bootstrap/bootstrap.go index c8669f5..dafac8e 100644 --- a/pkg/bootstrap/bootstrap.go +++ b/pkg/bootstrap/bootstrap.go @@ -16,8 +16,8 @@ package bootstrap import ( "context" + "encoding/json" "strings" - "time" "github.com/Mandala/go-log" @@ -88,13 +88,21 @@ func ProvideBootstrap( return nil, err } - if found, err := principals.Load(ctx, principal.Unauthenticated); err != nil { - return nil, err - } else if found == nil { + if found, _ := principals.Load(ctx, &principal.LoadRequest{ + Kind: &principal.LoadRequest_ID{ + ID: principal.Unauthenticated, + }}); found == nil { + claimBytes, err := json.Marshal(map[string]string{ + "name": "Unauthenticated Principal", + "source": "bootstrap", + }) + if err != nil { + return nil, err + } + p := &principal.Principal{ - PasswordHash: " ", // This value is invalid bcrypt - ID: principal.Unauthenticated, - Label: "Unauthenticated Principal", + ID: principal.Unauthenticated, + Claims: claimBytes, } req := &principal.EnsureRequest{Principal: p} if _, err := principals.Ensure(ctx, req); errors.Is(err, util.ErrVersionSkew) { diff --git a/pkg/cmd/cli/auth.go b/pkg/cmd/cli/auth.go index 2b10e02..fac6a2e 100644 --- a/pkg/cmd/cli/auth.go +++ b/pkg/cmd/cli/auth.go @@ -14,7 +14,14 @@ package cli import ( - "github.com/bobvawter/cacheroach/api/auth" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "github.com/bobvawter/cacheroach/api/session" "github.com/bobvawter/cacheroach/api/token" "github.com/spf13/cobra" @@ -28,33 +35,55 @@ func (c *CLI) auth() *cobra.Command { } top.AddCommand( &cobra.Command{ - Use: "login https://username[:password]@cacheroach.server/", - Short: "log into a cacheroach server", - Long: "if a password is not specified, " + - "it will be securely prompted for", - Args: cobra.ExactArgs(1), + Use: "login https://cacheroach.server", + Short: "Log in via OIDC authentication", + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - u, err := c.configureHostname(args[0], true) + l, err := net.ListenTCP("tcp4", &net.TCPAddr{ + IP: net.IPv4(127, 0, 0, 1), + }) if err != nil { return err } - password, _ := u.User.Password() + c.logger.Debugf("listening on %s", l.Addr()) + go func() { + <-cmd.Context().Done() + _ = l.Close() + }() - conn, err := c.conn(cmd.Context()) - if err != nil { - return err - } - client := auth.NewAuthClient(conn) - resp, err := client.Login(cmd.Context(), &auth.LoginRequest{ - Handle: "username:" + u.User.Username(), - Password: password, - }) + u, err := url.Parse(args[0]) if err != nil { return err } + u.Path = "/_/v0/provision" + u.RawQuery = url.Values{"port": []string{strconv.Itoa(l.Addr().(*net.TCPAddr).Port)}}.Encode() + fmt.Printf("Navigate to %s\n", u) - c.configureSession(resp.Session, resp.Token) - c.configDirty = true + _ = http.Serve(l, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + defer l.Close() + + w.WriteHeader(http.StatusAccepted) + w.Write([]byte("You can close this window now." + + "")) + + if err := req.ParseForm(); err != nil { + c.logger.Errorf("could not parse data: %v", err) + return + } + + cfg := req.Form.Get("config") + if cfg == "" { + c.logger.Error("request missing config") + return + } + + if err := json.NewDecoder(strings.NewReader(cfg)).Decode(&c.Config); err != nil { + c.logger.Errorf("could not decode configuration") + return + } + c.configDirty = true + fmt.Println("Success!") + })) return nil }, @@ -74,7 +103,7 @@ func (c *CLI) auth() *cobra.Command { c.logger.Warnf("could not invalidate session on server: %v", err) } - c.config.Token = "" + c.Config.Token = "" c.configDirty = true return nil }, @@ -84,7 +113,7 @@ func (c *CLI) auth() *cobra.Command { Short: "log in using an authentication token", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - u, err := c.configureHostname(args[0], false) + u, err := c.ConfigureHostname(args[0], false) if err != nil { return err } diff --git a/pkg/cmd/cli/bootstrap.go b/pkg/cmd/cli/bootstrap.go index 2c19dd1..cff4b7e 100644 --- a/pkg/cmd/cli/bootstrap.go +++ b/pkg/cmd/cli/bootstrap.go @@ -38,7 +38,7 @@ type bootstrap struct { func (c *CLI) boostrap() *cobra.Command { params := &bootstrap{CLI: c} ret := &cobra.Command{ - Use: "bootstrap [flags] https://username[:password]@cacheroach.server/", + Use: "bootstrap [flags] https://cacheroach.server/", Short: "create a super-user principal using the server's HMAC key", Long: "This command should be used to create an initial user on a newly-created " + "cacheroach installation. It requires access to the server's HMAC key that is used " + @@ -56,10 +56,6 @@ func (c *CLI) boostrap() *cobra.Command { } func (b *bootstrap) execute(ctx context.Context, args []string) error { - u, err := b.configureHostname(args[0], false) - if err != nil { - return err - } if b.hmacKey == "" { return errors.New("supertoken is required") } @@ -85,26 +81,18 @@ func (b *bootstrap) execute(ctx context.Context, args []string) error { return err } - p := &principal.Principal{ - Handles: []string{"username:" + u.User.Username()}, - Label: u.User.Username(), - ID: principal.NewID(), - } - if pwd, ok := u.User.Password(); ok { - p.PasswordSet = pwd - } - req := &principal.EnsureRequest{Principal: p} - _, err = principal.NewPrincipalsClient(conn).Ensure(ctx, req) + req := &principal.EnsureRequest{Principal: &principal.Principal{}} + p, err := principal.NewPrincipalsClient(conn).Ensure(ctx, req) if err != nil { return err } - b.logger.Tracef("created principal: %s", p.ID.AsUUID()) + b.logger.Tracef("created principal: %s", p.Principal.ID.AsUUID()) resp, err := token.NewTokensClient(conn).Issue(ctx, &token.IssueRequest{ Template: &session.Session{ ExpiresAt: timestamppb.New(time.Now().AddDate(0, 0, b.validDays)), Note: "cli bootstrap", - PrincipalId: p.ID, + PrincipalId: p.Principal.ID, Scope: &session.Scope{Kind: &session.Scope_SuperToken{SuperToken: true}}, }, }) @@ -113,7 +101,7 @@ func (b *bootstrap) execute(ctx context.Context, args []string) error { } b.logger.Tracef("issued session: %s", resp.Issued.ID.AsUUID()) - b.configureSession(resp.Issued, resp.Token) + b.ConfigureSession(resp.Issued, resp.Token) b.configDirty = true return nil } diff --git a/pkg/cmd/cli/cli.go b/pkg/cmd/cli/cli.go index 4c350fd..0550980 100644 --- a/pkg/cmd/cli/cli.go +++ b/pkg/cmd/cli/cli.go @@ -23,6 +23,7 @@ import ( "os" "github.com/Mandala/go-log" + "github.com/bobvawter/cacheroach/pkg/cmd/cli/config" "github.com/pkg/errors" "github.com/spf13/cobra" "golang.org/x/oauth2" @@ -33,7 +34,7 @@ import ( // CLI contains common state for the command-line tooling. type CLI struct { - config + config.Config configDirty bool configFile string @@ -142,7 +143,7 @@ func (c *CLI) start(*cobra.Command, []string) error { return err } defer f.Close() - return json.NewDecoder(f).Decode(&c.config) + return json.NewDecoder(f).Decode(&c.Config) } func (c *CLI) stop(*cobra.Command, []string) error { @@ -150,7 +151,7 @@ func (c *CLI) stop(*cobra.Command, []string) error { return nil } p := os.ExpandEnv(c.configFile) - if err := c.config.writeToFile(p); err != nil { + if err := c.Config.WriteToFile(p); err != nil { return err } c.logger.Infof("wrote configuration to: %s", p) diff --git a/pkg/cmd/cli/config.go b/pkg/cmd/cli/config/config.go similarity index 67% rename from pkg/cmd/cli/config.go rename to pkg/cmd/cli/config/config.go index 7c08369..96ca1fb 100644 --- a/pkg/cmd/cli/config.go +++ b/pkg/cmd/cli/config/config.go @@ -11,11 +11,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -package cli +// Package config contains a JSON-serializable configuration file for +// use by the CLI tooling. This is a separate package to allow a +// browser-based user to download a ready-to-run configuration file from +// the server. +package config import ( - "context" "encoding/json" + "errors" + "io" "net/url" "os" "path/filepath" @@ -25,13 +30,12 @@ import ( "github.com/bobvawter/cacheroach/api/session" "github.com/bobvawter/cacheroach/api/tenant" "github.com/bobvawter/cacheroach/api/token" - "github.com/pkg/errors" "golang.org/x/term" "google.golang.org/protobuf/proto" ) -// config contains the JSON-serializable configuration data. -type config struct { +// Config contains the JSON-serializable configuration data. +type Config struct { DefaultTenant *tenant.ID Host string Insecure bool @@ -39,9 +43,9 @@ type config struct { Token string } -// clone returns a deep copy of the config. -func (c *config) clone() *config { - ret := &config{ +// Clone returns a deep copy of the Config. +func (c *Config) Clone() *Config { + ret := &Config{ Host: c.Host, Insecure: c.Insecure, Token: c.Token, @@ -55,11 +59,11 @@ func (c *config) clone() *config { return ret } -// configureHostname parses the given host as a URL and updates the Host +// ConfigureHostname parses the given host as a URL and updates the Host // and Insecure fields. The url must include a username and may include // a password. If no password is provided, then one will be read in a // secure fashion from the console. -func (c *config) configureHostname(urlString string, requirePassword bool) (*url.URL, error) { +func (c *Config) ConfigureHostname(urlString string, requirePassword bool) (*url.URL, error) { u, err := url.Parse(urlString) if err != nil { return nil, err @@ -103,15 +107,23 @@ func (c *config) configureHostname(urlString string, requirePassword bool) (*url return u, nil } -// configureSession extracts the elements from the IssueResponse. -func (c *config) configureSession(sn *session.Session, tkn *token.Token) { +// ConfigureSession extracts the elements from the IssueResponse. +func (c *Config) ConfigureSession(sn *session.Session, tkn *token.Token) { c.Session = sn c.Token = tkn.Jwt } -// writeToFile writes the configuration to disk. This method will create +// WriteTo writes the configuration to the given writer. +func (c *Config) WriteTo(w io.Writer) (int64, error) { + counter := &countingWriter{Writer: w} + e := json.NewEncoder(counter) + e.SetIndent("", " ") + return counter.count, e.Encode(c) +} + +// WriteToFile writes the configuration to disk. This method will create // any necessary directories. -func (c *config) writeToFile(out string) error { +func (c *Config) WriteToFile(out string) error { out, err := filepath.Abs(out) if err != nil { return err @@ -124,24 +136,17 @@ func (c *config) writeToFile(out string) error { return err } defer f.Close() - - w := json.NewEncoder(f) - w.SetIndent("", " ") - return w.Encode(c) -} - -// A wrapper around a proper credentials that will disable the GRPC code -// path requiring secure transport. -type insecureCredentials struct { - token string + _, err = c.WriteTo(f) + return err } -func (c *insecureCredentials) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { - return map[string]string{ - "authorization": "Bearer " + c.token, - }, nil +type countingWriter struct { + io.Writer + count int64 } -func (c *insecureCredentials) RequireTransportSecurity() bool { - return false +func (w *countingWriter) Write(p []byte) (int, error) { + n, err := w.Writer.Write(p) + w.count += int64(n) + return n, err } diff --git a/pkg/store/auth/test_rig.go b/pkg/cmd/cli/creds.go similarity index 57% rename from pkg/store/auth/test_rig.go rename to pkg/cmd/cli/creds.go index 8a28c25..4c620fe 100644 --- a/pkg/store/auth/test_rig.go +++ b/pkg/cmd/cli/creds.go @@ -11,30 +11,24 @@ // See the License for the specific language governing permissions and // limitations under the License. -//+build wireinject - -package auth +package cli import ( "context" - - "github.com/bobvawter/cacheroach/pkg/store/principal" - "github.com/bobvawter/cacheroach/pkg/store/storetesting" - "github.com/bobvawter/cacheroach/pkg/store/token" - "github.com/google/wire" ) -type rig struct { - auth *Server - principals *principal.Server +// A wrapper around a proper credentials that will disable the GRPC code +// path requiring secure transport. +type insecureCredentials struct { + token string +} + +func (c *insecureCredentials) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + return map[string]string{ + "authorization": "Bearer " + c.token, + }, nil } -func testRig(ctx context.Context) (*rig, func(), error) { - panic(wire.Build( - Set, - storetesting.Set, - principal.Set, - token.Set, - wire.Struct(new(rig), "*"), - )) +func (c *insecureCredentials) RequireTransportSecurity() bool { + return false } diff --git a/pkg/cmd/cli/file.go b/pkg/cmd/cli/file.go index 516e406..7d6be5e 100644 --- a/pkg/cmd/cli/file.go +++ b/pkg/cmd/cli/file.go @@ -59,7 +59,7 @@ func (c *CLI) file() *cobra.Command { } } if tnt == nil { - tnt = c.config.Session.GetScope().GetOnLocation().GetTenantId() + tnt = c.Config.Session.GetScope().GetOnLocation().GetTenantId() } if tnt == nil { return errors.New("--tenant required") diff --git a/pkg/cmd/cli/principal.go b/pkg/cmd/cli/principal.go index e3380d7..5b8795c 100644 --- a/pkg/cmd/cli/principal.go +++ b/pkg/cmd/cli/principal.go @@ -14,6 +14,7 @@ package cli import ( + "encoding/json" "io" "strconv" "time" @@ -34,7 +35,7 @@ func (c *CLI) principal() *cobra.Command { Short: "principal management", } - var createOut, label, password string + var createDomain, createOut string create := &cobra.Command{ Use: "create ", Short: "create a principal", @@ -45,20 +46,20 @@ func (c *CLI) principal() *cobra.Command { if err != nil { return err } + claimBytes, err := json.Marshal(map[string]string{ + "name": username, + "source": "cli principal", + }) + if err != nil { + return err + } req := &principal.EnsureRequest{ Principal: &principal.Principal{ - ID: principal.NewID(), - Handles: []string{"username:" + username}, + ID: principal.NewID(), + Claims: claimBytes, + EmailDomain: createDomain, }, } - if label == "" { - req.Principal.Label = username - } else { - req.Principal.Label = label - } - if password != "" { - req.Principal.PasswordSet = password - } prn, err := principal.NewPrincipalsClient(conn).Ensure(cmd.Context(), req) if err != nil { return err @@ -79,14 +80,14 @@ func (c *CLI) principal() *cobra.Command { return err } - cfg := c.config.clone() + cfg := c.Config.Clone() cfg.Session = iss.Issued cfg.Token = iss.Token.Jwt if createOut == "" { createOut = username + ".cfg" } - if err := cfg.writeToFile(createOut); err != nil { + if err := cfg.WriteToFile(createOut); err != nil { return err } c.logger.Infof("Wrote configuration to %s", createOut) @@ -96,12 +97,11 @@ func (c *CLI) principal() *cobra.Command { return nil }, } + create.Flags().StringVar(&createDomain, "emailDomain", "", + "create a unique principal that represents all principals with "+ + "an email address in the given domain") create.Flags().StringVarP(&createOut, "out", "o", "", "write a new configuration file, defaults to username.cfg") - create.Flags().StringVar(&label, "label", "", - "set the principal's label (defaults to username)") - create.Flags().StringVar(&password, "password", "", - "set a password when creating the principal") ret.AddCommand( create, @@ -120,7 +120,7 @@ func (c *CLI) principal() *cobra.Command { } out := newTabs() defer out.Close() - out.Printf("ID\tVersion\tLabel\tHandles\n") + out.Printf("ID\tVersion\tLabel\tDomain\tClaims\n") for { p, err := data.Recv() if errors.Is(err, io.EOF) { @@ -128,11 +128,7 @@ func (c *CLI) principal() *cobra.Command { } else if err != nil { return err } - out.Printf("%s\t%d\t%s", p.ID.AsUUID(), p.Version, p.Label) - for i := range p.Handles { - out.Printf("\t%s", p.Handles[i]) - } - out.Printf("\n") + out.Printf("%s\t%d\t%s\t%s\t%s\n", p.ID.AsUUID(), p.Version, p.Label, p.EmailDomain, p.Claims) } return nil }, diff --git a/pkg/cmd/cli/session.go b/pkg/cmd/cli/session.go index 1ac0e79..eadd40f 100644 --- a/pkg/cmd/cli/session.go +++ b/pkg/cmd/cli/session.go @@ -112,11 +112,11 @@ func (c *CLI) session() *cobra.Command { } if out != "" { - cfg := c.config.clone() + cfg := c.Config.Clone() cfg.DefaultTenant = data.Issued.GetScope().GetOnLocation().GetTenantId() cfg.Session = data.Issued cfg.Token = data.Token.Jwt - if err := cfg.writeToFile(out); err != nil { + if err := cfg.WriteToFile(out); err != nil { return err } } diff --git a/pkg/cmd/start/wire_gen.go b/pkg/cmd/start/wire_gen.go index 6163370..9e9f9db 100644 --- a/pkg/cmd/start/wire_gen.go +++ b/pkg/cmd/start/wire_gen.go @@ -15,9 +15,9 @@ import ( "github.com/bobvawter/cacheroach/pkg/server" "github.com/bobvawter/cacheroach/pkg/server/common" "github.com/bobvawter/cacheroach/pkg/server/diag" + "github.com/bobvawter/cacheroach/pkg/server/oidc" "github.com/bobvawter/cacheroach/pkg/server/rest" "github.com/bobvawter/cacheroach/pkg/server/rpc" - "github.com/bobvawter/cacheroach/pkg/store/auth" "github.com/bobvawter/cacheroach/pkg/store/blob" "github.com/bobvawter/cacheroach/pkg/store/config" "github.com/bobvawter/cacheroach/pkg/store/fs" @@ -47,31 +47,31 @@ func newInjector(contextContext context.Context, cacheConfig *cache.Config, conf } healthz := rest.ProvideHealthz(pool, logger) debugMux := rest.ProvideDebugMux(handler, healthz) + latchWrapper := rest.ProvideLatchWrapper(busyLatch) + pProfWrapper := rest.ProvidePProfWrapper() cacheCache, cleanup, err := cache.ProvideCache(contextContext, factory, cacheConfig, logger) if err != nil { return nil, nil, err } store, cleanup2 := blob.ProvideStore(contextContext, cacheCache, configConfig, pool, logger) - tokenServer, err := token.ProvideServer(configConfig, pool, logger) - if err != nil { - cleanup2() - cleanup() - return nil, nil, err - } - enforcerEnforcer := enforcer.ProvideEnforcer(logger, tokenServer) fsStore, cleanup3, err := fs.ProvideStore(contextContext, store, configConfig, pool, logger) if err != nil { cleanup2() cleanup() return nil, nil, err } - pProfWrapper := rest.ProvidePProfWrapper() - latchWrapper := rest.ProvideLatchWrapper(busyLatch) principalServer := &principal.Server{ Config: configConfig, DB: pool, Logger: logger, } + tokenServer, err := token.ProvideServer(configConfig, pool, logger) + if err != nil { + cleanup3() + cleanup2() + cleanup() + return nil, nil, err + } tenantServer := &tenant.Server{ DB: pool, Logger: logger, @@ -87,7 +87,14 @@ func newInjector(contextContext context.Context, cacheConfig *cache.Config, conf cleanup() return nil, nil, err } - sessionWrapper := rest.ProvideSessionWrapper(bootstrapper, tokenServer) + connector, err := oidc.ProvideConnector(contextContext, factory, bootstrapper, commonConfig, logger, principalServer, tokenServer) + if err != nil { + cleanup3() + cleanup2() + cleanup() + return nil, nil, err + } + sessionWrapper := rest.ProvideSessionWrapper(bootstrapper, connector, tokenServer) vHostMap, cleanup4, err := common.ProvideVHostMap(contextContext, logger, vhostServer) if err != nil { cleanup3() @@ -96,10 +103,20 @@ func newInjector(contextContext context.Context, cacheConfig *cache.Config, conf return nil, nil, err } vHostWrapper := rest.ProvideVHostWrapper(logger, vHostMap) + cliConfigHandler := rest.ProvideCLIConfigHandler(commonConfig, latchWrapper, pProfWrapper, sessionWrapper, vHostWrapper) + enforcerEnforcer := enforcer.ProvideEnforcer(logger, tokenServer) fileHandler := rest.ProvideFileHandler(store, enforcerEnforcer, fsStore, logger, pProfWrapper, latchWrapper, sessionWrapper, vHostWrapper) wrapper := metrics.ProvideWrapper(factory) + provision, err := rest.ProvideProvision(commonConfig, connector, logger, principalServer, pProfWrapper, latchWrapper, sessionWrapper, vHostWrapper) + if err != nil { + cleanup4() + cleanup3() + cleanup2() + cleanup() + return nil, nil, err + } retrieve := rest.ProvideRetrieve(logger, fsStore, pProfWrapper, latchWrapper, sessionWrapper, vHostWrapper) - authInterceptor, err := rpc.ProvideAuthInterceptor(logger, tokenServer) + authInterceptor, err := rpc.ProvideAuthInterceptor(connector, logger, tokenServer) if err != nil { cleanup4() cleanup3() @@ -118,11 +135,6 @@ func newInjector(contextContext context.Context, cacheConfig *cache.Config, conf Logger: logger, Mapper: vHostMap, } - authServer := &auth.Server{ - DB: pool, - Principals: principalServer, - Tokens: tokenServer, - } diags := &diag.Diags{} fsServer := &fs.Server{ Config: configConfig, @@ -137,7 +149,7 @@ func newInjector(contextContext context.Context, cacheConfig *cache.Config, conf cleanup() return nil, nil, err } - grpcServer, err := rpc.ProvideRPC(logger, authInterceptor, busyInterceptor, elideInterceptor, interceptor, vHostInterceptor, authServer, diags, fsServer, principalServer, tenantServer, tokenServer, uploadServer, vhostServer) + grpcServer, err := rpc.ProvideRPC(logger, authInterceptor, busyInterceptor, elideInterceptor, interceptor, vHostInterceptor, diags, fsServer, principalServer, tenantServer, tokenServer, uploadServer, vhostServer) if err != nil { cleanup4() cleanup3() @@ -145,7 +157,7 @@ func newInjector(contextContext context.Context, cacheConfig *cache.Config, conf cleanup() return nil, nil, err } - publicMux := rest.ProvidePublicMux(fileHandler, wrapper, retrieve, grpcServer) + publicMux := rest.ProvidePublicMux(cliConfigHandler, connector, fileHandler, wrapper, provision, retrieve, grpcServer) serverServer, cleanup5, err := server.ProvideServer(contextContext, busyLatch, v, commonConfig, debugMux, logger, publicMux) if err != nil { cleanup4() diff --git a/pkg/enforcer/enforcer_test.go b/pkg/enforcer/enforcer_test.go index 700e3d1..4ce459f 100644 --- a/pkg/enforcer/enforcer_test.go +++ b/pkg/enforcer/enforcer_test.go @@ -62,16 +62,11 @@ func Test(t *testing.T) { { ctx: ctx, src: &principal.Principal{ - ID: pID, - Label: "Label", - Version: 1, - Handles: []string{"foo", "bar"}, - PasswordHash: "hash", - PasswordSet: "setting", + ID: pID, + Version: 1, }, expected: &principal.Principal{ ID: pID, - Label: "Label", Version: 1, }, }, @@ -82,18 +77,12 @@ func Test(t *testing.T) { Scope: &session.Scope{Kind: &session.Scope_OnPrincipal{OnPrincipal: pID}}, }), src: &principal.Principal{ - ID: pID, - Label: "Label", - Version: 1, - Handles: []string{"foo", "bar"}, - PasswordHash: "hash", - PasswordSet: "setting", + ID: pID, + Version: 1, }, expected: &principal.Principal{ ID: pID, - Label: "Label", Version: 1, - Handles: []string{"foo", "bar"}, }, }, { @@ -104,19 +93,12 @@ func Test(t *testing.T) { }), dir: capabilities.Direction_REQUEST, src: &principal.Principal{ - ID: pID, - Label: "Label", - Version: 1, - Handles: []string{"foo", "bar"}, - PasswordHash: "hash", - PasswordSet: "setting", + ID: pID, + Version: 1, }, expected: &principal.Principal{ - ID: pID, - Label: "Label", - Version: 1, - Handles: []string{"foo", "bar"}, - PasswordSet: "setting", + ID: pID, + Version: 1, }, }, } @@ -155,9 +137,7 @@ func TestEval(t *testing.T) { pID := principal.NewID() if _, err := rig.principals.Ensure(ctx, &principal.EnsureRequest{Principal: &principal.Principal{ - ID: pID, - Label: "test user", - PasswordHash: " ", + ID: pID, }}); !a.NoError(err) { return } diff --git a/pkg/server/common/config.go b/pkg/server/common/config.go index 6215d40..a0c1b43 100644 --- a/pkg/server/common/config.go +++ b/pkg/server/common/config.go @@ -16,21 +16,34 @@ package common import ( "time" + "net/http" + "github.com/spf13/pflag" ) // Config contains all of the flag-worthy configuration for a Server. type Config struct { + AssumeSecure bool // Treat all incoming connections as though they were secure. BindAddr string // The network address to bind the API to. CertBundle string // A path to a certificate bundle file. DebugAddr string // If set serve additional debugging endpoints. GenerateSelfSigned bool // If true, a self-signed certificate will be created. GracePeriod time.Duration // The time to allow connections to drain. PrivateKey string // A path to a private key file. + + OIDC struct { + ClientID string + ClientSecret string + Domains []string // Allowable domains for provisioning + Issuer string // OIDC discovery URL + } } // Bind adds flags to the FlagSet. func (c *Config) Bind(flags *pflag.FlagSet) { + flags.BoolVar(&c.AssumeSecure, "assumeSecure", false, + "set this if you have a TLS load-balancer connecting to cacheroach "+ + "over an unencrypted connection") flags.StringVar(&c.BindAddr, "bindAddr", ":0", "the local IP and port to bind to") flags.StringVar(&c.CertBundle, "certs", "", @@ -43,4 +56,19 @@ func (c *Config) Bind(flags *pflag.FlagSet) { "the grace period for draining connections") flags.StringVar(&c.PrivateKey, "key", "", "a file that contains a private key") + + flags.StringVar(&c.OIDC.ClientID, "oidcClientID", "", + "the OIDC client ID") + flags.StringVar(&c.OIDC.ClientSecret, "oidcClientSecret", "", + "the OIDC client secret") + flags.StringSliceVar(&c.OIDC.Domains, "oidcDomains", nil, + "acceptable user email domains") + flags.StringVar(&c.OIDC.Issuer, "oidcIssuer", "", + "the OIDC discovery base URL") + +} + +// IsSecure returns true if the request should be considered secure. +func (c *Config) IsSecure(r *http.Request) bool { + return c.AssumeSecure || r.TLS != nil } diff --git a/pkg/server/oidc/connector.go b/pkg/server/oidc/connector.go new file mode 100644 index 0000000..529ac44 --- /dev/null +++ b/pkg/server/oidc/connector.go @@ -0,0 +1,543 @@ +// Copyright 2021 The Cockroach Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package oidc provide OpenID Connect integration for cacheroach. +package oidc + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Mandala/go-log" + "github.com/bobvawter/cacheroach/api/capabilities" + "github.com/bobvawter/cacheroach/api/principal" + "github.com/bobvawter/cacheroach/api/session" + "github.com/bobvawter/cacheroach/api/token" + "github.com/bobvawter/cacheroach/pkg/bootstrap" + "github.com/bobvawter/cacheroach/pkg/server/common" + "github.com/bobvawter/cacheroach/pkg/store/util" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/google/wire" + lru "github.com/hashicorp/golang-lru" + "github.com/pkg/errors" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "golang.org/x/oauth2" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// These cookies are used during the authentication flow. +const ( + AuthorizationCookie = "authorization" + DestinationCookie = "cacheroach-destination" + NonceCookie = "cacheroach-oidc-nonce" + StateCookie = "cacheroach-oidc-state" +) + +// ReceivePath will be added to the redirect path. +const ReceivePath = "/_/oidc/receive" + +// ErrPermanentFailure indicates that the principal must be reauthorized +// by the OIDC provider in order to be usable. +var ErrPermanentFailure = errors.New("OIDC refresh needed") + +// Set is used by wire. +var Set = wire.NewSet( + ProvideConnector, +) + +// Connector encapsulates the OIDC integration. +type Connector struct { + cache *lru.TwoQueueCache // UUID -> time.Time for refreshes + cfg *common.Config + logger *log.Logger + p *oidc.Provider + principals principal.PrincipalsServer + tokens token.TokensServer + unauthenticated *principal.ID + + cacheHits prometheus.Counter + cacheMisses prometheus.Counter + dbHits prometheus.Counter + dbMisses prometheus.Counter + principalsCreated prometheus.Counter + principalsInvalidated prometheus.Counter + principalsRefreshed prometheus.Counter + redirects prometheus.Counter + refreshFailures prometheus.Counter + sessionsCreated prometheus.Counter +} + +// ProvideConnector is called by wire. +func ProvideConnector( + ctx context.Context, + auto promauto.Factory, + bt *bootstrap.Bootstrapper, + cfg *common.Config, + logger *log.Logger, + principals principal.PrincipalsServer, + tokens token.TokensServer, +) (*Connector, error) { + for i := range cfg.OIDC.Domains { + cfg.OIDC.Domains[i] = strings.ToLower(cfg.OIDC.Domains[i]) + } + cache, err := lru.New2Q(10000) + if err != nil { + return nil, err + } + c := &Connector{ + cache: cache, + cfg: cfg, + logger: logger, + principals: principals, + tokens: tokens, + unauthenticated: bt.Unauthenticated, + cacheHits: auto.NewCounter(prometheus.CounterOpts{ + Name: "oidc_cache_hit_total", + Help: "the number of cached principal validations", + }), + cacheMisses: auto.NewCounter(prometheus.CounterOpts{ + Name: "oidc_cache_miss_total", + Help: "the number of un-cached principal validations", + }), + dbHits: auto.NewCounter(prometheus.CounterOpts{ + Name: "oidc_db_hit_total", + Help: "the number of times a valid token was found in the DB", + }), + dbMisses: auto.NewCounter(prometheus.CounterOpts{ + Name: "oidc_db_miss_total", + Help: "the number of times a valid token was not found in the DB", + }), + principalsCreated: auto.NewCounter(prometheus.CounterOpts{ + Name: "oidc_created_principal_total", + Help: "the number of new Principals created from OIDC redirects", + }), + principalsInvalidated: auto.NewCounter(prometheus.CounterOpts{ + Name: "oidc_invalidated_principal_total", + Help: "the number of times a Principal was canceled by the OIDC provider", + }), + principalsRefreshed: auto.NewCounter(prometheus.CounterOpts{ + Name: "oidc_refreshed_principal_total", + Help: "the number of times a Principal had its refresh token updated", + }), + redirects: auto.NewCounter(prometheus.CounterOpts{ + Name: "oidc_redirect_total", + Help: "the number of redirects send to the OIDC provider", + }), + refreshFailures: auto.NewCounter(prometheus.CounterOpts{ + Name: "oidc_refresh_failure_total", + Help: "the number of times the OIDC provider could not be contacted", + }), + sessionsCreated: auto.NewCounter(prometheus.CounterOpts{ + Name: "oidc_created_session_total", + Help: "the number of cacheroach auth sessions created", + }), + } + + ok := cfg.OIDC.ClientID != "" && + cfg.OIDC.ClientSecret != "" && + cfg.OIDC.Issuer != "" && + len(cfg.OIDC.Domains) > 0 + if !ok { + logger.Infof("OIDC integration not configured") + return c, nil + } + + c.p, err = oidc.NewProvider(ctx, cfg.OIDC.Issuer) + if err == nil { + end := c.p.Endpoint() + logger.Infof("OIDC integration ready: %s %s", end.AuthURL, end.TokenURL) + } + + return c, err +} + +// Redirect implements an endpoint to redirect a caller to the OIDC +// provider. Once the flow has been successfully completed, the caller +// will be redirected to the given destination. +// +// This method returns true if it was able to generate a response. +func (c *Connector) Redirect(w http.ResponseWriter, r *http.Request, dest *url.URL) bool { + if c.p == nil { + return false + } + const cookieAge = 60 + secure := c.cfg.IsSecure(r) + + http.SetCookie(w, &http.Cookie{ + HttpOnly: true, + MaxAge: 60, + Name: DestinationCookie, + Path: ReceivePath, + Secure: secure, + Value: dest.String(), + }) + + // State is relayed via the series of HTTP redirects. + var state = make([]byte, 16) + if _, err := rand.Read(state); err != nil { + c.logger.Errorf("could not create random state: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return true + } + + stateString := base64.RawURLEncoding.EncodeToString(state) + http.SetCookie(w, &http.Cookie{ + HttpOnly: true, + MaxAge: cookieAge, + Name: StateCookie, + Path: ReceivePath, + Secure: secure, + Value: stateString, + }) + + // Nonce is relayed via the received JWT token. + var nonce = make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + c.logger.Errorf("could not create random nonce: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return true + } + + nonceString := base64.RawURLEncoding.EncodeToString(nonce) + http.SetCookie(w, &http.Cookie{ + HttpOnly: true, + MaxAge: cookieAge, + Name: NonceCookie, + Path: ReceivePath, + Secure: secure, + Value: nonceString, + }) + + cfg := c.oauthConfig(r) + u := cfg.AuthCodeURL(stateString, oauth2.AccessTypeOffline, + oauth2.ApprovalForce, oidc.Nonce(nonceString)) + http.Redirect(w, r, u, http.StatusFound) + c.redirects.Inc() + return true +} + +// Receive a JWT token from the OIDC provider to create a principal and +// issue a session token. +func (c *Connector) Receive(w http.ResponseWriter, r *http.Request) { + cfg := c.oauthConfig(r) + + nonce, err := r.Cookie(NonceCookie) + if err != nil { + http.Error(w, "no nonce", http.StatusBadRequest) + return + } + + state, err := r.Cookie(StateCookie) + if err != nil { + http.Error(w, "no state", http.StatusBadRequest) + return + } + if state.Value != r.URL.Query().Get("state") { + http.Error(w, "state mismatch", http.StatusBadRequest) + return + } + + exchanged, err := cfg.Exchange(r.Context(), r.URL.Query().Get("code")) + if err != nil { + c.logger.Errorf("could not exchange OIDC code: %v", err) + http.Error(w, "could not exchange OIDC code", http.StatusBadRequest) + return + } + id, ok := exchanged.Extra("id_token").(string) + if !ok { + http.Error(w, "oauth2 token missing id_token OIDC field", http.StatusBadRequest) + return + } + + verified, err := c.p.Verifier(&oidc.Config{ClientID: c.cfg.OIDC.ClientID}).Verify(r.Context(), id) + if err != nil { + c.logger.Errorf("could not verify OIDC token: %v", err) + http.Error(w, "could not verify OIDC token", http.StatusBadRequest) + return + } + if nonce.Value != verified.Nonce { + http.Error(w, "exchanged nonce did not match", http.StatusBadRequest) + return + } + + if c.logger.IsDebug() { + var raw json.RawMessage + _ = verified.Claims(&raw) + c.logger.Tracef("verified OIDC credentials: %s", string(raw)) + } + + var claims struct { + Name string `json:"name"` + Email string `json:"email"` + Verified bool `json:"email_verified"` + } + + if err := verified.Claims(&claims); err != nil { + c.logger.Errorf("could not extract claims: %v", err) + http.Error(w, "could not extract claims", http.StatusBadRequest) + return + } + + if !claims.Verified { + http.Error(w, "email not verified", http.StatusBadRequest) + return + } + + claims.Email = strings.ToLower(claims.Email) + ok = false + for _, domain := range c.cfg.OIDC.Domains { + if strings.HasSuffix(claims.Email, "@"+domain) { + ok = true + break + } + } + if !ok { + http.Error(w, "email not in approved domains list", http.StatusBadRequest) + return + } + + c.logger.Tracef("ensuring account based on OIDC email: %s", claims.Email) + + // Create a fake super-token. + ctx := session.WithSession(r.Context(), &session.Session{ + Scope: &session.Scope{ + Kind: &session.Scope_SuperToken{ + SuperToken: true, + }}}) + + // We'll always try to create a principal, relying on the uniqueness + // constraints on the handles to prevent creation of unnecessary + // data. + var pID *principal.ID + { + var raw json.RawMessage + _ = verified.Claims(&raw) + resp, err := c.principals.Ensure(ctx, &principal.EnsureRequest{ + Principal: &principal.Principal{ + Claims: raw, + ID: principal.NewID(), + RefreshAfter: timestamppb.New(exchanged.Expiry), + RefreshStatus: principal.TokenStatus_VALID, + RefreshToken: exchanged.RefreshToken, + }}) + if err == nil { + pID = resp.GetPrincipal().GetID() + c.principalsCreated.Inc() + } + } + + // We couldn't create a principal, so load it by email address. + if pID == nil { + save: + p, err := c.principals.Load(ctx, &principal.LoadRequest{ + Kind: &principal.LoadRequest_Email{ + Email: claims.Email, + }}) + if err != nil { + c.logger.Errorf("could not create or find user: %v", err) + http.Error(w, "could not create or find user", http.StatusInternalServerError) + return + } + pID = p.ID + + // Record the updated token status. + p.RefreshAfter = timestamppb.New(exchanged.Expiry) + p.RefreshStatus = principal.TokenStatus_VALID + p.RefreshToken = exchanged.RefreshToken + _, err = c.principals.Ensure(ctx, &principal.EnsureRequest{Principal: p}) + if errors.Is(err, util.ErrVersionSkew) { + goto save + } else if err != nil { + c.logger.Errorf("could not save refreshed OIDC token: %v", err) + http.Error(w, "could not save refreshed OIDC token", http.StatusInternalServerError) + return + } + c.principalsRefreshed.Inc() + } + + issued, err := c.tokens.Issue(ctx, &token.IssueRequest{Template: &session.Session{ + Note: "created via OIDC login", + PrincipalId: pID, + Capabilities: capabilities.All(), + Scope: &session.Scope{Kind: &session.Scope_OnPrincipal{OnPrincipal: pID}}, + ExpiresAt: timestamppb.New(time.Now().AddDate(10, 0, 0).Round(time.Minute)), + }}) + if err != nil { + c.logger.Errorf("could not issue OIDC token: %v", err) + http.Error(w, "could not issue OIDC token", http.StatusInternalServerError) + return + } + + c.logger.Tracef("issued session %s from OIDC token", issued.Issued.ID.AsUUID()) + c.sessionsCreated.Inc() + + http.SetCookie(w, &http.Cookie{ + Expires: issued.Issued.ExpiresAt.AsTime(), + HttpOnly: true, + Name: AuthorizationCookie, + Path: "/", + SameSite: http.SameSiteLaxMode, + Secure: true, + Value: issued.Token.Jwt, + }) + + dest := "/" + if found, err := r.Cookie(DestinationCookie); err == nil { + dest = found.Value + } + http.Redirect(w, r, dest, http.StatusFound) +} + +// Validate ensures that the Principal is still valid, according to the OIDC provider. +func (c *Connector) Validate(ctx context.Context, pID *principal.ID) error { + // The unauthenticated user is always valid. + if pID.Zero() || proto.Equal(pID, c.unauthenticated) { + return nil + } + now := time.Now() + isAcceptable := func(when time.Time) bool { + return when.IsZero() || when.After(now) + } + + u := pID.AsUUID() + if v, ok := c.cache.Get(u); ok { + refreshAfter := v.(time.Time) + if isAcceptable(refreshAfter) { + c.cacheHits.Inc() + return nil + } + } + c.cacheMisses.Inc() + + // We're using the principal's version for locking purposes, so + // we may need to restart this sequence. +top: + p, err := c.principals.Load(ctx, &principal.LoadRequest{Kind: &principal.LoadRequest_ID{ID: pID}}) + if err != nil { + return err + } + + if p.RefreshStatus == principal.TokenStatus_PERMANENT_FAILURE { + return ErrPermanentFailure + } + + // Principals created via RPCs won't have a refresh token. Treat + // them as ok, and cache the result to keep from repeatedly hitting + // the database. + if p.RefreshToken == "" { + c.cache.Add(u, now.Add(time.Hour)) + return nil + } + + refreshAfter := p.GetRefreshAfter().AsTime() + if isAcceptable(refreshAfter) { + c.dbHits.Inc() + c.cache.Add(u, refreshAfter) + return nil + } + c.dbMisses.Inc() + + // Add some grace time while we refresh the principal. + refreshAfter = now.Add(5 * time.Minute) + c.cache.Add(u, refreshAfter) + + // Mark the principal as undergoing a refresh cycle. + p.RefreshAfter = timestamppb.New(refreshAfter) + p.RefreshStatus = principal.TokenStatus_REFRESHING + resp, err := c.principals.Ensure(ctx, &principal.EnsureRequest{Principal: p}) + if errors.Is(err, util.ErrVersionSkew) { + goto top + } else if err != nil { + return err + } + p = resp.Principal + + // Now we can attempt to refresh the token. + var tkn *oauth2.Token + if c.p == nil { + tkn = &oauth2.Token{ + AccessToken: "", + RefreshToken: "", + Expiry: now.Add(time.Hour), + } + } else { + src := c.oauthConfig(nil).TokenSource(ctx, &oauth2.Token{RefreshToken: p.RefreshToken}) + tkn, err = src.Token() + } + + if r := (*oauth2.RetrieveError)(nil); errors.As(err, &r) { + // OAuth2 spec says that bad tokens are reported as 400's. If + // the user falls into this state, they can go back through the + // provisioning process to restore access. + if r.Response.StatusCode == http.StatusBadRequest { + c.logger.Warnf("OIDC principal %s in permanent failure mode: %s", u, string(r.Body)) + c.cache.Remove(u) + p.RefreshAfter = timestamppb.New(time.Time{}) + p.RefreshStatus = principal.TokenStatus_PERMANENT_FAILURE + c.principalsInvalidated.Inc() + } else { + // Treat it as a temporary failure in case of 500's etc... + c.logger.Warnf("could not refresh OIDC token for principal %s: %s", u, string(r.Body)) + c.refreshFailures.Inc() + return err + } + } else if err != nil { + return err + } else { + // Everything is refreshed, store the new data. + c.cache.Add(u, tkn.Expiry) + p.RefreshAfter = timestamppb.New(tkn.Expiry) + p.RefreshStatus = principal.TokenStatus_VALID + if tkn.RefreshToken != "" { + p.RefreshToken = tkn.RefreshToken + } + c.logger.Tracef("refreshed OIDC token for %s", u) + c.principalsRefreshed.Inc() + } + + // Save the update, but only log the error since we do want the + // enclosing API request to succeed. + if _, err := c.principals.Ensure(ctx, &principal.EnsureRequest{Principal: p}); err != nil { + c.logger.Warnf("error while refreshing OIDC token for %s: %v", u, err) + } + return err +} + +func (c *Connector) oauthConfig(r *http.Request) *oauth2.Config { + cfg := &oauth2.Config{ + ClientID: c.cfg.OIDC.ClientID, + ClientSecret: c.cfg.OIDC.ClientSecret, + Endpoint: c.p.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "email", "profile"}, + } + if r != nil { + u := &url.URL{ + Host: r.Host, + Path: ReceivePath, + Scheme: "https", + } + if !c.cfg.IsSecure(r) { + u.Scheme = "http" + } + cfg.RedirectURL = u.String() + } + return cfg +} diff --git a/pkg/server/rest/handlers.go b/pkg/server/rest/handlers.go index 3201927..2ec23e2 100644 --- a/pkg/server/rest/handlers.go +++ b/pkg/server/rest/handlers.go @@ -15,21 +15,73 @@ package rest import ( "context" + "encoding/json" + "html/template" + "net" "net/http" "os" + "strconv" "time" "github.com/Mandala/go-log" "github.com/bobvawter/cacheroach/api/capabilities" + "github.com/bobvawter/cacheroach/api/principal" "github.com/bobvawter/cacheroach/api/session" "github.com/bobvawter/cacheroach/api/vhost" + cliConfig "github.com/bobvawter/cacheroach/pkg/cmd/cli/config" "github.com/bobvawter/cacheroach/pkg/enforcer" + "github.com/bobvawter/cacheroach/pkg/server/common" + "github.com/bobvawter/cacheroach/pkg/server/oidc" "github.com/bobvawter/cacheroach/pkg/store/blob" "github.com/bobvawter/cacheroach/pkg/store/fs" "github.com/jackc/pgx/v4/pgxpool" "github.com/pkg/errors" + "google.golang.org/protobuf/proto" ) +// CLIConfigHandler allows a user to download a ready-to-run CLI configuration file. +type CLIConfigHandler http.Handler + +// ProvideCLIConfigHandler is called by wire. +func ProvideCLIConfigHandler( + cfg *common.Config, + latchWrapper LatchWrapper, + pprofWrapper PProfWrapper, + sessionWrapper SessionWrapper, + vhostWrapper VHostWrapper, +) CLIConfigHandler { + fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-disposition", `attachment; filename="cacheroach.cfg"`) + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = makeConfig(r.Context(), r, cfg.IsSecure(r)).WriteTo(w) + }) + + return wrap(fn, latchWrapper, vhostWrapper, sessionWrapper, pprofWrapper) +} + +func makeConfig(ctx context.Context, r *http.Request, secure bool) *cliConfig.Config { + sn := session.FromContext(ctx) + vh := vhost.FromContext(ctx) + + host := r.Host + if _, _, err := net.SplitHostPort(host); err == nil { + // Already has a port number + } else if secure { + host += ":443" + } else { + host += ":80" + } + + return &cliConfig.Config{ + DefaultTenant: vh.GetTenantId(), + Host: host, + Insecure: !secure, + Session: sn, + Token: extractJWT(r), + } +} + // FileHandler implements a traditional web-server. type FileHandler http.Handler @@ -140,7 +192,7 @@ func ProvideFileHandler( } }) - return pprofWrapper(sessionWrapper(vHostWrapper(latchWrapper(fn)))) + return wrap(fn, latchWrapper, vHostWrapper, sessionWrapper, pprofWrapper) } // Healthz verifies database connectivity. @@ -206,5 +258,89 @@ func ProvideRetrieve( http.ServeContent(w, req, f.Name(), f.ModTime(), f) }) - return pprofWrapper(sessionWrapper(vHostWrapper(latchWrapper(fn)))) + return wrap(fn, latchWrapper, vHostWrapper, sessionWrapper, pprofWrapper) +} + +// Provision hosts a trivial landing page to trigger OIDC login. +type Provision http.Handler + +// ProvideProvision is called by wire. +func ProvideProvision( + cfg *common.Config, + connector *oidc.Connector, + logger *log.Logger, + principals principal.PrincipalsServer, + pprofWrapper PProfWrapper, + latchWrapper LatchWrapper, + sessionWrapper SessionWrapper, + vHostWrapper VHostWrapper, +) (Provision, error) { + type Data struct { + Config string + P *principal.Principal + Port int + } + + tmpl, err := template.New("landing").Parse(` + +Hello Cacheroach! +Hello {{.P.Label}} aka Principal {{.P.ID.AsUUID}}!
+{{if and .Config .Port}} +
+ +
+ +{{end}} +Install CLI locally with go get github.com/bobvawter/cacheroach
+Click here to download a CLI configuration file and move it to $HOME/.cacheroach/config
+Test your setup with cacheroach auth whoami + +`) + if err != nil { + return nil, err + } + + fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sn := session.FromContext(r.Context()) + + if proto.Equal(principal.Unauthenticated, sn.PrincipalId) { + if !connector.Redirect(w, r, r.URL) { + http.Error(w, "not logged in and no OIDC provider", http.StatusUnauthorized) + } + return + } + + p, err := principals.Load(r.Context(), &principal.LoadRequest{ + Kind: &principal.LoadRequest_ID{ID: sn.PrincipalId}}) + if err != nil { + logger.Errorf("could not load principal %s: %v", sn.PrincipalId.AsUUID(), err) + http.Error(w, "could not load principal", http.StatusInternalServerError) + return + } + + data := &Data{P: p} + if raw := r.URL.Query().Get("port"); raw != "" { + data.Port, err = strconv.Atoi(raw) + if err != nil { + http.Error(w, "bad port value", http.StatusBadRequest) + return + } + + buf, _ := json.Marshal(makeConfig(r.Context(), r, cfg.IsSecure(r))) + data.Config = string(buf) + } + + w.Header().Add("content-type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + logger.Errorf("could not execute template: %v", err) + } + }) + return wrap(fn, latchWrapper, vHostWrapper, sessionWrapper, pprofWrapper), nil +} + +func wrap(h http.Handler, wrappers ...func(http.Handler) http.Handler) http.Handler { + for i := range wrappers { + h = wrappers[i](h) + } + return h } diff --git a/pkg/server/rest/mux.go b/pkg/server/rest/mux.go index 6e4c064..5f7a4e0 100644 --- a/pkg/server/rest/mux.go +++ b/pkg/server/rest/mux.go @@ -21,6 +21,7 @@ import ( "github.com/bobvawter/cacheroach/pkg/metrics" "github.com/bobvawter/cacheroach/pkg/server/diag" + "github.com/bobvawter/cacheroach/pkg/server/oidc" "google.golang.org/grpc" ) @@ -31,8 +32,11 @@ type PublicMux struct { // ProvidePublicMux is called by wire. func ProvidePublicMux( + cliConfig CLIConfigHandler, + connector *oidc.Connector, fileHandler FileHandler, measure metrics.Wrapper, + provision Provision, retrieve Retrieve, rpc *grpc.Server, ) PublicMux { @@ -40,6 +44,9 @@ func ProvidePublicMux( retrieve = measure(retrieve, "files") mux := http.NewServeMux() + mux.HandleFunc(oidc.ReceivePath, connector.Receive) + mux.Handle("/_/v0/config", cliConfig) + mux.Handle("/_/v0/provision", provision) mux.Handle("/_/v0/retrieve/", retrieve) mux.Handle("/_/", http.NotFoundHandler()) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/server/rest/rest.go b/pkg/server/rest/rest.go index e622376..23205d3 100644 --- a/pkg/server/rest/rest.go +++ b/pkg/server/rest/rest.go @@ -18,12 +18,14 @@ import "github.com/google/wire" // Set is used by wire. var Set = wire.NewSet( + ProvideCLIConfigHandler, ProvideDebugMux, ProvideFileHandler, ProvideHealthz, ProvideLatchWrapper, - ProvidePublicMux, ProvidePProfWrapper, + ProvideProvision, + ProvidePublicMux, ProvideRetrieve, ProvideSessionWrapper, ProvideVHostWrapper, diff --git a/pkg/server/rest/wrappers.go b/pkg/server/rest/wrappers.go index 5064d1c..56bd9f1 100644 --- a/pkg/server/rest/wrappers.go +++ b/pkg/server/rest/wrappers.go @@ -25,6 +25,7 @@ import ( "github.com/bobvawter/cacheroach/api/vhost" "github.com/bobvawter/cacheroach/pkg/bootstrap" "github.com/bobvawter/cacheroach/pkg/server/common" + "github.com/bobvawter/cacheroach/pkg/server/oidc" ) // A Wrapper alters the behavior of an http.Handler. @@ -69,24 +70,27 @@ type SessionWrapper Wrapper // ProvideSessionWrapper is called by wire. func ProvideSessionWrapper( bootstrap *bootstrap.Bootstrapper, + connector *oidc.Connector, tokens token.TokensServer, ) SessionWrapper { return func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - var jwt string - if hdr := req.Header.Get("authorization"); strings.Index(hdr, "Bearer ") == 0 { - jwt = hdr[7:] - } else if p := req.URL.Query().Get("access_token"); p != "" { - jwt = p - } - sn := bootstrap.PublicSession - if jwt != "" { + var sn *session.Session + // Find a JWT token somewhere in the request and validate it. + if jwt := extractJWT(req); jwt != "" { var err error if sn, err = tokens.Validate(req.Context(), &token.Token{Jwt: jwt}); err != nil { - w.WriteHeader(http.StatusUnauthorized) - return + sn = nil } } + // Ensure that the principal is still valid. + if sn != nil && connector.Validate(req.Context(), sn.PrincipalId) != nil { + sn = nil + } + // Fall back to an unauthenticated session. + if sn == nil { + sn = bootstrap.PublicSession + } ctx := session.WithSession(req.Context(), sn) req = req.WithContext(ctx) h.ServeHTTP(w, req) @@ -94,6 +98,20 @@ func ProvideSessionWrapper( } } +// extractJWT extracts an un-validated JWT from the request. +func extractJWT(req *http.Request) string { + if hdr := req.Header.Get("authorization"); strings.Index(hdr, "Bearer ") == 0 { + return hdr[7:] + } + if p := req.URL.Query().Get("access_token"); p != "" { + return p + } + if c, err := req.Cookie("authorization"); err == nil { + return c.Value + } + return "" +} + // VHostWrapper will inject a VHost reference into the context. type VHostWrapper Wrapper diff --git a/pkg/server/rpc/auth.go b/pkg/server/rpc/auth.go index ee0a182..cb579bf 100644 --- a/pkg/server/rpc/auth.go +++ b/pkg/server/rpc/auth.go @@ -23,6 +23,7 @@ import ( "github.com/bobvawter/cacheroach/api/session" "github.com/bobvawter/cacheroach/api/token" "github.com/bobvawter/cacheroach/pkg/enforcer" + "github.com/bobvawter/cacheroach/pkg/server/oidc" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" @@ -42,15 +43,17 @@ var defaultRule = &capabilities.Rule{Kind: &capabilities.Rule_AuthStatus_{ // Unauthorized requests will be rejected unless the service // implementation implements DefaultSession. type AuthInterceptor struct { - Enforcer *enforcer.Enforcer - Logger *log.Logger - Tokens token.TokensServer + Connector *oidc.Connector + Enforcer *enforcer.Enforcer + Logger *log.Logger + Tokens token.TokensServer methodRules map[string]*capabilities.Rule } // ProvideAuthInterceptor is called by wire. func ProvideAuthInterceptor( + connector *oidc.Connector, logger *log.Logger, tokens token.TokensServer, ) (*AuthInterceptor, error) { @@ -76,6 +79,7 @@ func ProvideAuthInterceptor( &capabilities.Rule{Kind: &capabilities.Rule_AuthStatus_{AuthStatus: capabilities.Rule_PUBLIC}} return &AuthInterceptor{ + Connector: connector, Logger: logger, Tokens: tokens, methodRules: reqs, @@ -144,5 +148,14 @@ func (i *AuthInterceptor) get(ctx context.Context) (*session.Session, error) { if strings.ToLower(data[0][:7]) != "bearer " { return nil, nil } - return i.Tokens.Validate(ctx, &token.Token{Jwt: data[0][7:]}) + sn, err := i.Tokens.Validate(ctx, &token.Token{Jwt: data[0][7:]}) + if err != nil { + return nil, err + } + if sn != nil { + if err := i.Connector.Validate(ctx, sn.PrincipalId); err != nil { + return nil, err + } + } + return sn, err } diff --git a/pkg/server/rpc/rpc.go b/pkg/server/rpc/rpc.go index d56c792..ab14cb7 100644 --- a/pkg/server/rpc/rpc.go +++ b/pkg/server/rpc/rpc.go @@ -19,7 +19,6 @@ import ( "runtime/pprof" "github.com/Mandala/go-log" - "github.com/bobvawter/cacheroach/api/auth" "github.com/bobvawter/cacheroach/api/diag" "github.com/bobvawter/cacheroach/api/file" "github.com/bobvawter/cacheroach/api/principal" @@ -51,7 +50,6 @@ func ProvideRPC( elide *ElideInterceptor, met *metrics.Interceptor, vh *VHostInterceptor, - ath auth.AuthServer, dia diag.DiagsServer, fls file.FilesServer, prn principal.PrincipalsServer, @@ -96,7 +94,6 @@ func ProvideRPC( ) reflection.Register(rpc) - auth.RegisterAuthServer(rpc, ath) diag.RegisterDiagsServer(rpc, dia) file.RegisterFilesServer(rpc, fls) principal.RegisterPrincipalsServer(rpc, prn) diff --git a/pkg/server/server.go b/pkg/server/server.go index 2243cc8..dd4d664 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -28,6 +28,7 @@ import ( "github.com/Mandala/go-log" "github.com/bobvawter/cacheroach/pkg/server/common" "github.com/bobvawter/cacheroach/pkg/server/diag" + "github.com/bobvawter/cacheroach/pkg/server/oidc" "github.com/bobvawter/cacheroach/pkg/server/rest" "github.com/bobvawter/cacheroach/pkg/server/rpc" "github.com/google/wire" @@ -41,6 +42,7 @@ import ( var Set = wire.NewSet( common.Set, diag.Set, + oidc.Set, rest.Set, rpc.Set, ProvideCertificates, diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index 20b28b7..7f02ec9 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -26,7 +26,6 @@ import ( "io" - "github.com/bobvawter/cacheroach/api/auth" "github.com/bobvawter/cacheroach/api/capabilities" "github.com/bobvawter/cacheroach/api/file" "github.com/bobvawter/cacheroach/api/principal" @@ -34,12 +33,11 @@ import ( "github.com/bobvawter/cacheroach/api/tenant" "github.com/bobvawter/cacheroach/api/token" "github.com/bobvawter/cacheroach/api/upload" + "github.com/bobvawter/cacheroach/pkg/claims" "github.com/stretchr/testify/assert" "golang.org/x/oauth2" "google.golang.org/grpc" - "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/oauth" - "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/emptypb" @@ -58,15 +56,10 @@ func TestSmoke(t *testing.T) { defer cleanup() pID := principal.NewID() - username := "you@example.com" - passwd := "Str0ngPassword!" p := &principal.Principal{ - Handles: []string{"username:" + username}, ID: pID, - Label: "Some User", Version: 0, } - a.NoError(p.SetPassword(passwd)) if _, err := rig.principals.Ensure(ctx, &principal.EnsureRequest{Principal: p}); !a.NoError(err) { return } @@ -183,144 +176,70 @@ func TestSmoke(t *testing.T) { if !a.NoError(err) { return } - a.Equal(http.StatusUnauthorized, resp.StatusCode) - }) - - t.Run("log in and delegate", func(t *testing.T) { - a := assert.New(t) - - ath := auth.NewAuthClient(rig.Conn) - resp, err := ath.Login(ctx, - &auth.LoginRequest{Handle: "username:" + username, Password: passwd}) - if !a.NoError(err) { - return - } - a.Equal(pID.String(), resp.GetSession().GetPrincipalId().String()) - a.NotEmpty(resp.GetToken().GetJwt()) - creds := grpc.PerRPCCredentials(oauth.NewOauthAccess(&oauth2.Token{AccessToken: resp.Token.Jwt})) - - tokens := token.NewTokensClient(rig.Conn) - sn, err := tokens.Current(ctx, &emptypb.Empty{}, creds) - if a.NoError(err) { - resp.GetSession().Note = "" // The note is stripped. - a.Equal(resp.GetSession().String(), sn.String()) - a.True(resp.GetSession().Capabilities.GetDelegate()) - } - - // Create a delegated token. - template := &session.Session{ - Capabilities: &capabilities.Capabilities{Read: true}, - PrincipalId: pID, - Scope: &session.Scope{Kind: &session.Scope_OnLocation{OnLocation: &session.Location{ - TenantId: tID, - Path: "/foo/bar", - }}}, - ExpiresAt: timestamppb.New(time.Now().Add(time.Hour)), - } - - restricted, err := tokens.Issue(ctx, &token.IssueRequest{Template: template}, creds) - if a.NoError(err) { - a.Equal("/foo/bar", restricted.GetIssued().GetScope().GetOnLocation().GetPath()) - a.NotEqual(resp.Token.Jwt, restricted.Token.Jwt) - } - - // Try creating a token on something we normally can't access. - tOther := tenant.NewID() - if _, err := rig.tenants.Ensure(ctx, &tenant.EnsureRequest{Tenant: &tenant.Tenant{ - ID: tOther, - Label: "More testing", - }}); !a.NoError(err) { - return - } - - badTemplate := proto.Clone(template).(*session.Session) - badTemplate.GetScope().GetOnLocation().TenantId = tOther - _, err = tokens.Issue(ctx, &token.IssueRequest{Template: badTemplate}, creds) - if a.Error(err) { - s, _ := status.FromError(err) - a.Equal(codes.PermissionDenied, s.Code()) - } - - // Try creating a token for another principal. - pOther := principal.NewID() - if _, err := rig.principals.Ensure(ctx, &principal.EnsureRequest{ - Principal: &principal.Principal{ - ID: pOther, - Label: "More testing", - PasswordSet: "Nothing", - }}); !a.NoError(err) { - return - } - - badTemplate = proto.Clone(template).(*session.Session) - badTemplate.PrincipalId = pOther - _, err = tokens.Issue(ctx, &token.IssueRequest{Template: badTemplate}, creds) - if a.Error(err) { - s, _ := status.FromError(err) - a.Equal(codes.PermissionDenied, s.Code()) - } + a.Equal(http.StatusNotFound, resp.StatusCode) }) t.Run("testLoadPrincipalWithElision", func(t *testing.T) { a := assert.New(t) - // We should have stored a hash in the original object. p := proto.Clone(p).(*principal.Principal) - a.NotEmpty(p.PasswordHash) - p.PasswordHash = "" principals := principal.NewPrincipalsClient(rig.Conn) - loaded, err := principals.Load(ctx, pID, superTokenOpt) + loaded, err := principals.Load(ctx, + &principal.LoadRequest{Kind: &principal.LoadRequest_ID{ID: pID}}, superTokenOpt) if a.NoError(err) { - a.Equal(p.String(), loaded.String()) + a.True(loaded.Version >= p.Version) + a.Nil(loaded.RefreshAfter) + a.Empty(loaded.RefreshToken) + a.Empty(loaded.Claims) } in, err := principals.List(ctx, &emptypb.Empty{}, superTokenOpt) if a.NoError(err) { - loaded, err := in.Recv() - if a.NoError(err) { - a.Equal(p.String(), loaded.String()) + received, err := in.Recv() + if a.NoError(err) && proto.Equal(pID, received.ID) { + a.Equal(loaded.String(), received.String()) } } a.NoError(in.CloseSend()) }) - t.Run("createPrincipalUsingSuperToken", func(t *testing.T) { + t.Run("testBootstrapFlow", func(t *testing.T) { a := assert.New(t) - principals := principal.NewPrincipalsClient(rig.Conn) - ret, err := principals.Ensure(ctx, &principal.EnsureRequest{Principal: &principal.Principal{ - Label: "created", - PasswordSet: "woot!", - }}, superTokenOpt) + _, tkn, err := claims.Sign(time.Now(), &session.Session{ + ExpiresAt: timestamppb.New(time.Now().Add(time.Minute)), + Note: "CLI bootstrap supertoken", + PrincipalId: nil, + Scope: &session.Scope{Kind: &session.Scope_SuperToken{SuperToken: true}}, + }, rig.cfg.SigningKeys[0]) if !a.NoError(err) { return } - a.NotNil(ret.Principal.ID) - a.Equal(int64(1), ret.Principal.Version) - }) - - t.Run("whoAmIFlow", func(t *testing.T) { - a := assert.New(t) + creds := grpc.PerRPCCredentials(oauth.NewOauthAccess(&oauth2.Token{AccessToken: tkn.Jwt})) - ath := auth.NewAuthClient(rig.Conn) - resp, err := ath.Login(ctx, &auth.LoginRequest{Handle: "username:" + username, Password: passwd}) - if !a.NoError(err) { - return - } + principals := principal.NewPrincipalsClient(rig.Conn) + ret, err := principals.Ensure(ctx, + &principal.EnsureRequest{Principal: &principal.Principal{}}, + creds) if !a.NoError(err) { return } - sn := resp.Session + a.NotNil(ret.Principal.ID) + a.Equal(int64(1), ret.Principal.Version) - creds := grpc.PerRPCCredentials(oauth.NewOauthAccess( - &oauth2.Token{AccessToken: resp.Token.Jwt})) - p, err := principal.NewPrincipalsClient(rig.Conn).Load(ctx, sn.PrincipalId, creds) - if !a.NoError(err) { - return + tokens := token.NewTokensClient(rig.Conn) + resp, err := tokens.Issue(ctx, &token.IssueRequest{ + Template: &session.Session{ + ExpiresAt: timestamppb.New(time.Now().AddDate(0, 0, 1)), + Note: "cli bootstrap", + PrincipalId: ret.Principal.ID, + Scope: &session.Scope{Kind: &session.Scope_SuperToken{SuperToken: true}}, + }, + }, creds) + if a.NoError(err) { + a.NotEmpty(resp.Token.Jwt) } - a.NotEmpty(p.Handles) - a.Equal(pID.String(), p.ID.String()) }) t.Run("rpc put and http get", func(t *testing.T) { diff --git a/pkg/server/test_rig.go b/pkg/server/test_rig.go index 9417bc6..87bd91e 100644 --- a/pkg/server/test_rig.go +++ b/pkg/server/test_rig.go @@ -28,6 +28,7 @@ import ( "github.com/bobvawter/cacheroach/pkg/enforcer" "github.com/bobvawter/cacheroach/pkg/server/common" "github.com/bobvawter/cacheroach/pkg/store" + "github.com/bobvawter/cacheroach/pkg/store/config" "github.com/bobvawter/cacheroach/pkg/store/storetesting" "github.com/google/wire" ) @@ -35,6 +36,7 @@ import ( type rig struct { *Server certs []tls.Certificate + cfg *config.Config principals principal.PrincipalsServer tenants tenant.TenantsServer tokens token.TokensServer diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 281ea81..373ac5c 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -18,10 +18,11 @@ import ( "github.com/bobvawter/cacheroach/pkg/metrics" "github.com/bobvawter/cacheroach/pkg/server/common" "github.com/bobvawter/cacheroach/pkg/server/diag" + "github.com/bobvawter/cacheroach/pkg/server/oidc" "github.com/bobvawter/cacheroach/pkg/server/rest" "github.com/bobvawter/cacheroach/pkg/server/rpc" - "github.com/bobvawter/cacheroach/pkg/store/auth" "github.com/bobvawter/cacheroach/pkg/store/blob" + "github.com/bobvawter/cacheroach/pkg/store/config" "github.com/bobvawter/cacheroach/pkg/store/fs" "github.com/bobvawter/cacheroach/pkg/store/principal" "github.com/bobvawter/cacheroach/pkg/store/storetesting" @@ -56,6 +57,8 @@ func testRig(ctx context.Context) (*rig, func(), error) { } healthz := rest.ProvideHealthz(pool, logger) debugMux := rest.ProvideDebugMux(handler, healthz) + latchWrapper := rest.ProvideLatchWrapper(busyLatch) + pProfWrapper := rest.ProvidePProfWrapper() cacheConfig, cleanup2, err := storetesting.ProvideCacheConfig() if err != nil { cleanup() @@ -68,7 +71,7 @@ func testRig(ctx context.Context) (*rig, func(), error) { return nil, nil, err } store, cleanup4 := blob.ProvideStore(ctx, cacheCache, configConfig, pool, logger) - server, err := token.ProvideServer(configConfig, pool, logger) + fsStore, cleanup5, err := fs.ProvideStore(ctx, store, configConfig, pool, logger) if err != nil { cleanup4() cleanup3() @@ -76,22 +79,20 @@ func testRig(ctx context.Context) (*rig, func(), error) { cleanup() return nil, nil, err } - enforcerEnforcer := enforcer.ProvideEnforcer(logger, server) - fsStore, cleanup5, err := fs.ProvideStore(ctx, store, configConfig, pool, logger) + server := &principal.Server{ + Config: configConfig, + DB: pool, + Logger: logger, + } + tokenServer, err := token.ProvideServer(configConfig, pool, logger) if err != nil { + cleanup5() cleanup4() cleanup3() cleanup2() cleanup() return nil, nil, err } - pProfWrapper := rest.ProvidePProfWrapper() - latchWrapper := rest.ProvideLatchWrapper(busyLatch) - principalServer := &principal.Server{ - Config: configConfig, - DB: pool, - Logger: logger, - } tenantServer := &tenant.Server{ DB: pool, Logger: logger, @@ -100,7 +101,7 @@ func testRig(ctx context.Context) (*rig, func(), error) { DB: pool, Logger: logger, } - bootstrapper, err := bootstrap.ProvideBootstrap(ctx, store, pool, fsStore, logger, principalServer, server, tenantServer, vhostServer) + bootstrapper, err := bootstrap.ProvideBootstrap(ctx, store, pool, fsStore, logger, server, tokenServer, tenantServer, vhostServer) if err != nil { cleanup5() cleanup4() @@ -109,7 +110,16 @@ func testRig(ctx context.Context) (*rig, func(), error) { cleanup() return nil, nil, err } - sessionWrapper := rest.ProvideSessionWrapper(bootstrapper, server) + connector, err := oidc.ProvideConnector(ctx, factory, bootstrapper, config, logger, server, tokenServer) + if err != nil { + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return nil, nil, err + } + sessionWrapper := rest.ProvideSessionWrapper(bootstrapper, connector, tokenServer) vHostMap, cleanup6, err := common.ProvideVHostMap(ctx, logger, vhostServer) if err != nil { cleanup5() @@ -120,10 +130,22 @@ func testRig(ctx context.Context) (*rig, func(), error) { return nil, nil, err } vHostWrapper := rest.ProvideVHostWrapper(logger, vHostMap) + cliConfigHandler := rest.ProvideCLIConfigHandler(config, latchWrapper, pProfWrapper, sessionWrapper, vHostWrapper) + enforcerEnforcer := enforcer.ProvideEnforcer(logger, tokenServer) fileHandler := rest.ProvideFileHandler(store, enforcerEnforcer, fsStore, logger, pProfWrapper, latchWrapper, sessionWrapper, vHostWrapper) wrapper := metrics.ProvideWrapper(factory) + provision, err := rest.ProvideProvision(config, connector, logger, server, pProfWrapper, latchWrapper, sessionWrapper, vHostWrapper) + if err != nil { + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return nil, nil, err + } retrieve := rest.ProvideRetrieve(logger, fsStore, pProfWrapper, latchWrapper, sessionWrapper, vHostWrapper) - authInterceptor, err := rpc.ProvideAuthInterceptor(logger, server) + authInterceptor, err := rpc.ProvideAuthInterceptor(connector, logger, tokenServer) if err != nil { cleanup6() cleanup5() @@ -144,11 +166,6 @@ func testRig(ctx context.Context) (*rig, func(), error) { Logger: logger, Mapper: vHostMap, } - authServer := &auth.Server{ - DB: pool, - Principals: principalServer, - Tokens: server, - } diags := &diag.Diags{} fsServer := &fs.Server{ Config: configConfig, @@ -165,7 +182,7 @@ func testRig(ctx context.Context) (*rig, func(), error) { cleanup() return nil, nil, err } - grpcServer, err := rpc.ProvideRPC(logger, authInterceptor, busyInterceptor, elideInterceptor, interceptor, vHostInterceptor, authServer, diags, fsServer, principalServer, tenantServer, server, uploadServer, vhostServer) + grpcServer, err := rpc.ProvideRPC(logger, authInterceptor, busyInterceptor, elideInterceptor, interceptor, vHostInterceptor, diags, fsServer, server, tenantServer, tokenServer, uploadServer, vhostServer) if err != nil { cleanup6() cleanup5() @@ -175,7 +192,7 @@ func testRig(ctx context.Context) (*rig, func(), error) { cleanup() return nil, nil, err } - publicMux := rest.ProvidePublicMux(fileHandler, wrapper, retrieve, grpcServer) + publicMux := rest.ProvidePublicMux(cliConfigHandler, connector, fileHandler, wrapper, provision, retrieve, grpcServer) serverServer, cleanup7, err := ProvideServer(ctx, busyLatch, v, config, debugMux, logger, publicMux) if err != nil { cleanup6() @@ -189,9 +206,10 @@ func testRig(ctx context.Context) (*rig, func(), error) { serverRig := &rig{ Server: serverServer, certs: v, - principals: principalServer, + cfg: configConfig, + principals: server, tenants: tenantServer, - tokens: server, + tokens: tokenServer, vhosts: vhostServer, } return serverRig, func() { @@ -219,6 +237,7 @@ var ( type rig struct { *Server certs []tls.Certificate + cfg *config.Config principals principal2.PrincipalsServer tenants tenant2.TenantsServer tokens token2.TokensServer diff --git a/pkg/store/auth/auth.go b/pkg/store/auth/auth.go deleted file mode 100644 index 3a09a53..0000000 --- a/pkg/store/auth/auth.go +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright 2021 The Cockroach Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package auth - -import ( - "context" - "errors" - "fmt" - "net/url" - "time" - - "github.com/bobvawter/cacheroach/api/auth" - "github.com/bobvawter/cacheroach/api/capabilities" - "github.com/bobvawter/cacheroach/api/principal" - "github.com/bobvawter/cacheroach/api/session" - "github.com/bobvawter/cacheroach/api/token" - "github.com/bobvawter/cacheroach/pkg/store/util" - "github.com/google/wire" - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" - "golang.org/x/crypto/bcrypt" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/peer" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/timestamppb" -) - -var ( - // Set is used by wire. - Set = wire.NewSet( - wire.Struct(new(Server), "*"), - wire.Bind(new(auth.AuthServer), new(*Server)), - ) -) - -// Server implements the auth.AuthServer API. -type Server struct { - DB *pgxpool.Pool - Principals principal.PrincipalsServer - Tokens token.TokensServer - auth.UnsafeAuthServer `wire:"-"` -} - -var _ auth.AuthServer = (*Server)(nil) - -// Login implements AuthServer. -func (s *Server) Login(ctx context.Context, req *auth.LoginRequest) (*auth.LoginResponse, error) { - var id *principal.ID - err := util.Retry(ctx, func(ctx context.Context) error { - // Normalize handle - u, err := url.Parse(req.Handle) - if err != nil { - return err - } - row := s.DB.QueryRow(ctx, - "SELECT principals.principal, principals.pw_hash "+ - "FROM principals "+ - "INNER JOIN principal_handles "+ - "ON principals.principal = principal_handles.principal "+ - "WHERE principal_handles.urn = $1", u.String()) - - id = &principal.ID{} - var hash string - if err := row.Scan(id, &hash); errors.Is(err, pgx.ErrNoRows) { - id = nil - return nil - } else if err != nil { - return err - } - - if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil { - id = nil - } - return nil - }) - if err != nil { - return nil, err - } - if id == nil { - return nil, status.Error(codes.Unauthenticated, "") - } - - template := &session.Session{ - Capabilities: capabilities.All(), - ExpiresAt: timestamppb.New(time.Now().AddDate(0, 1, 0).Round(time.Second)), - PrincipalId: id, - Scope: &session.Scope{ - Kind: &session.Scope_OnPrincipal{OnPrincipal: id}, - }, - } - if p, ok := peer.FromContext(ctx); ok { - template.Note = fmt.Sprintf("login from %s", p.Addr) - } - resp, err := s.Tokens.Issue(ctx, &token.IssueRequest{Template: template}) - if err != nil { - return nil, err - } - - return &auth.LoginResponse{ - Session: resp.Issued, - Token: resp.Token, - }, nil -} diff --git a/pkg/store/auth/auth_test.go b/pkg/store/auth/auth_test.go deleted file mode 100644 index 824a137..0000000 --- a/pkg/store/auth/auth_test.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright 2021 The Cockroach Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package auth - -import ( - "context" - "testing" - "time" - - . "github.com/bobvawter/cacheroach/api/auth" - "github.com/bobvawter/cacheroach/api/principal" - "github.com/stretchr/testify/assert" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -func TestAuth(t *testing.T) { - a := assert.New(t) - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - - rig, cleanup, err := testRig(ctx) - if !a.NoError(err) { - return - } - defer cleanup() - - if !a.NoError(err) { - return - } - - const email = "email:you@example.com" - const pw = "Str0ngPassword!" - - p := &principal.Principal{ - Handles: []string{email}, - Label: "Some User", - ID: principal.NewID(), - Version: 0, - } - a.NoError(p.SetPassword(pw)) - - if _, err = rig.principals.Ensure(ctx, &principal.EnsureRequest{Principal: p}); !a.NoError(err) { - return - } - a.Equal(int64(1), p.Version) - - resp, err := rig.auth.Login(ctx, &LoginRequest{Handle: email, Password: pw}) - a.NoError(err) - a.NotEmpty(resp.GetToken().GetJwt()) - - _, err = rig.auth.Login(ctx, &LoginRequest{Handle: email, Password: ""}) - if a.Error(err) { - s, ok := status.FromError(err) - if a.True(ok) { - a.Equal(codes.Unauthenticated, s.Code()) - } - } - - _, err = rig.auth.Login(ctx, &LoginRequest{Handle: "email:nobody@example.com", Password: ""}) - if a.Error(err) { - s, ok := status.FromError(err) - if a.True(ok) { - a.Equal(codes.Unauthenticated, s.Code()) - } - } -} diff --git a/pkg/store/auth/wire_gen.go b/pkg/store/auth/wire_gen.go deleted file mode 100644 index 8c0cd5c..0000000 --- a/pkg/store/auth/wire_gen.go +++ /dev/null @@ -1,60 +0,0 @@ -// Code generated by Wire. DO NOT EDIT. - -//go:generate go run github.com/google/wire/cmd/wire -//+build !wireinject - -package auth - -import ( - "context" - "github.com/bobvawter/cacheroach/pkg/store/principal" - "github.com/bobvawter/cacheroach/pkg/store/storetesting" - "github.com/bobvawter/cacheroach/pkg/store/token" -) - -// Injectors from test_rig.go: - -func testRig(ctx context.Context) (*rig, func(), error) { - config, err := storetesting.ProvideStoreConfig() - if err != nil { - return nil, nil, err - } - logger := _wireLoggerValue - pool, cleanup, err := storetesting.ProvideDB(ctx, config, logger) - if err != nil { - return nil, nil, err - } - server := &principal.Server{ - Config: config, - DB: pool, - Logger: logger, - } - tokenServer, err := token.ProvideServer(config, pool, logger) - if err != nil { - cleanup() - return nil, nil, err - } - authServer := &Server{ - DB: pool, - Principals: server, - Tokens: tokenServer, - } - authRig := &rig{ - auth: authServer, - principals: server, - } - return authRig, func() { - cleanup() - }, nil -} - -var ( - _wireLoggerValue = storetesting.Logger -) - -// test_rig.go: - -type rig struct { - auth *Server - principals *principal.Server -} diff --git a/pkg/store/fs/server.go b/pkg/store/fs/server.go index ecce6c6..a19a95b 100644 --- a/pkg/store/fs/server.go +++ b/pkg/store/fs/server.go @@ -155,9 +155,9 @@ LEFT JOIN rope_length USING (tenant, hash) // Retrieve implements file.FilesServer. func (s *Server) Retrieve( - _ context.Context, req *file.RetrievalRequest, + ctx context.Context, req *file.RetrievalRequest, ) (*file.RetrievalResponse, error) { - sn, ret, err := s.retrievePath(req.Tenant, req.Path, req.Version, req.ValidFor.AsDuration()) + sn, ret, err := s.retrievePath(ctx, req.Tenant, req.Path, req.Version, req.ValidFor.AsDuration()) if err != nil { return nil, err } @@ -169,11 +169,12 @@ func (s *Server) Retrieve( // retrievePath cooks up a signed access path to retrieve the file. func (s *Server) retrievePath( - tID *tenant.ID, filePath string, version int64, validity time.Duration, + ctx context.Context, tID *tenant.ID, filePath string, version int64, validity time.Duration, ) (*session.Session, string, error) { - + parent := session.FromContext(ctx) sn := &session.Session{ Capabilities: &capabilities.Capabilities{Read: true}, + PrincipalId: parent.PrincipalId, Scope: &session.Scope{ Kind: &session.Scope_OnLocation{ OnLocation: &session.Location{ diff --git a/pkg/store/principal/principal.go b/pkg/store/principal/principal.go index f93f628..495111b 100644 --- a/pkg/store/principal/principal.go +++ b/pkg/store/principal/principal.go @@ -15,7 +15,7 @@ package principal import ( "context" - "net/url" + "strings" "time" "github.com/Mandala/go-log" @@ -25,9 +25,10 @@ import ( "github.com/google/wire" "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/pgxpool" - "github.com/pkg/errors" - "golang.org/x/sync/errgroup" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" ) // Set is used by wire. @@ -72,14 +73,7 @@ func (s *Server) Ensure( if p.ID == nil { p.ID = principal.NewID() } - if p.PasswordSet != "" { - if err := p.SetPassword(p.PasswordSet); err != nil { - return nil, err - } - } - if p.Version == 0 && p.PasswordHash == "" { - p.PasswordHash = " " // Not valid bcrypt. - } + err := util.Retry(ctx, func(ctx context.Context) error { tx, err := s.DB.Begin(ctx) if err != nil { @@ -88,42 +82,34 @@ func (s *Server) Ensure( defer tx.Rollback(ctx) row := tx.QueryRow(ctx, - "INSERT INTO principals (principal, label, pw_hash, version) "+ - "VALUES ($1, $2, $3, 1) "+ + "INSERT INTO principals ( "+ + "principal, email_domain, refresh_after, refresh_status, refresh_token, "+ + "claims, version"+ + ") VALUES ($1, $2, $3, $4, $5, $6, 1) "+ "ON CONFLICT (principal) "+ - "DO UPDATE SET (label, pw_hash, version) = "+ + "DO UPDATE SET (refresh_after, refresh_status, refresh_token, claims, version) = "+ "("+ - " IF (length(excluded.label)> 0, excluded.label, principals.label),"+ - " IF (length(excluded.pw_hash)> 0, excluded.pw_hash, principals.pw_hash),"+ + " IF (excluded.refresh_after > 0::TIMESTAMPTZ, excluded.refresh_after, principals.refresh_after),"+ + " IF (excluded.refresh_status > 0, excluded.refresh_status, principals.refresh_status),"+ + " IF (length(excluded.refresh_token) > 0, excluded.refresh_token, principals.refresh_token),"+ + " IFNULL (excluded.claims, principals.claims),"+ " principals.version + 1"+ - ") RETURNING label, pw_hash, version", p.ID, p.Label, p.PasswordHash) + ") RETURNING name, refresh_after, refresh_status, refresh_token, claims, version", + p.ID, strings.ToLower(p.EmailDomain), p.RefreshAfter.AsTime(), + p.RefreshStatus, p.RefreshToken, p.Claims) var pendingVersion int64 - if err := row.Scan(&p.Label, &p.PasswordHash, &pendingVersion); err != nil { + var refreshAfter time.Time + if err := row.Scan( + &p.Label, &refreshAfter, &p.RefreshStatus, &p.RefreshToken, + &p.Claims, &pendingVersion); err != nil { return err } + p.RefreshAfter = timestamppb.New(refreshAfter) if pendingVersion != p.Version+1 { return util.ErrVersionSkew } - if _, err := tx.Exec(ctx, - "DELETE FROM principal_handles WHERE principal = $1", p.ID); err != nil { - return err - } - - for i := range p.Handles { - // Normalize the handles before inserting - h, err := url.Parse(p.Handles[i]) - if err != nil { - return errors.Wrapf(err, "could not parse handle %q", p.Handles[i]) - } - if _, err := tx.Exec(ctx, - "INSERT INTO principal_handles (urn, principal) VALUES ($1, $2)", - h.String(), p.ID); err != nil { - return err - } - } - if err := tx.Commit(ctx); err != nil { return err } @@ -143,10 +129,8 @@ func (s *Server) List(_ *emptypb.Empty, out principal.Principals_ListServer) err defer tx.Rollback(ctx) rows, err := tx.Query(ctx, - "WITH "+ - "h AS (SELECT principal, array_agg(urn) AS urns FROM principal_handles GROUP BY principal), "+ - "p AS (SELECT principal, label, version FROM principals) "+ - "SELECT p.*, h.urns FROM p JOIN h ON p.principal = h.principal") + "SELECT principal, email_domain, name, claims, version "+ + "FROM principals") if err != nil { return err } @@ -156,7 +140,7 @@ func (s *Server) List(_ *emptypb.Empty, out principal.Principals_ListServer) err for rows.Next() { p := &principal.Principal{ID: &principal.ID{}} - if err := rows.Scan(p.ID, &p.Label, &p.Version, &p.Handles); err != nil { + if err := rows.Scan(p.ID, &p.EmailDomain, &p.Label, &p.Claims, &p.Version); err != nil { return err } @@ -169,42 +153,37 @@ func (s *Server) List(_ *emptypb.Empty, out principal.Principals_ListServer) err } // Load implements principal.PrincipalsServer. -func (s *Server) Load(ctx context.Context, id *principal.ID) (*principal.Principal, error) { - ret := &principal.Principal{ID: id} +func (s *Server) Load(ctx context.Context, req *principal.LoadRequest) (*principal.Principal, error) { + var col string + var val interface{} + + switch t := req.Kind.(type) { + case *principal.LoadRequest_Email: + col = "email" + val = strings.ToLower(t.Email) + case *principal.LoadRequest_ID: + col = "principal" + val = t.ID + case *principal.LoadRequest_EmailDomain: + col = "email_domain" + val = strings.ToLower(t.EmailDomain) + default: + return nil, status.Error(codes.Unimplemented, "unknown kind") + } + ret := &principal.Principal{ID: &principal.ID{}} err := util.Retry(ctx, func(ctx context.Context) error { - var eg errgroup.Group - eg.Go(func() error { - row := s.DB.QueryRow(ctx, - "SELECT label, pw_hash, version FROM principals WHERE principal = $1", id) - return row.Scan(&ret.Label, &ret.PasswordHash, &ret.Version) - }) - eg.Go(func() error { - rows, err := s.DB.Query(ctx, - "SELECT urn FROM principal_handles WHERE principal = $1", id) - if err != nil { - return err - } - defer rows.Close() - - var handles []string - for rows.Next() { - s := "" - if err := rows.Scan(&s); err != nil { - return err - } - u, err := url.Parse(s) - if err != nil { - return err - } - handles = append(handles, u.String()) - } - ret.Handles = handles - return nil - }) - return eg.Wait() + var refreshAfter time.Time + row := s.DB.QueryRow(ctx, + "SELECT principal, name, email_domain, refresh_after, refresh_status, refresh_token, "+ + "claims, version "+ + "FROM principals WHERE "+col+" = $1", val) + err := row.Scan(ret.ID, &ret.Label, &ret.EmailDomain, &refreshAfter, &ret.RefreshStatus, + &ret.RefreshToken, &ret.Claims, &ret.Version) + ret.RefreshAfter = timestamppb.New(refreshAfter) + return err }) if err == pgx.ErrNoRows { - return nil, nil + return nil, status.Error(codes.NotFound, req.String()) } return ret, err } @@ -225,7 +204,7 @@ func (s *Server) watch( lastVersion := int64(0) for { - toSend, err := s.Load(ctx, id) + toSend, err := s.Load(ctx, &principal.LoadRequest{Kind: &principal.LoadRequest_ID{ID: id}}) if err != nil { return err } diff --git a/pkg/store/principal/principal_test.go b/pkg/store/principal/principal_test.go index 3d905f6..0445982 100644 --- a/pkg/store/principal/principal_test.go +++ b/pkg/store/principal/principal_test.go @@ -15,6 +15,7 @@ package principal import ( "context" + "encoding/json" "testing" "time" @@ -23,7 +24,10 @@ import ( "github.com/bobvawter/cacheroach/pkg/store/util" "github.com/pkg/errors" "github.com/stretchr/testify/assert" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" ) func TestPrincipal(t *testing.T) { @@ -48,16 +52,19 @@ func TestPrincipal(t *testing.T) { } a.NoError(err) - const email = "email:you@example.com" - const pw = "Str0ngPassword!" - + const email = "you@example.com" + claimBytes, err := json.Marshal(map[string]string{ + "email": email, + "name": "Some User", + }) + if !a.NoError(err) { + return + } p := &Principal{ - Handles: []string{email}, - Label: "Some User", + Claims: claimBytes, ID: NewID(), Version: 0, } - a.NoError(p.SetPassword(pw)) if _, err := rig.p.Ensure(ctx, &EnsureRequest{Principal: p}); !a.NoError(err) { return @@ -86,23 +93,48 @@ func TestPrincipal(t *testing.T) { a.True(proto.Equal(x, p)) }) - t.Run("load", func(t *testing.T) { + t.Run("loadID", func(t *testing.T) { + a := assert.New(t) + p2, err := rig.p.Load(ctx, &LoadRequest{Kind: &LoadRequest_ID{ID: p.ID}}) + a.NoError(err) + a.NotNil(p2) + a.Truef(proto.Equal(p, p2), "%v vs. %v", p, p2) + a.Equal(p.String(), p2.String()) + }) + + t.Run("loadID404", func(t *testing.T) { + a := assert.New(t) + _, err := rig.p.Load(ctx, &LoadRequest{Kind: &LoadRequest_ID{ID: NewID()}}) + s, ok := status.FromError(err) + a.True(ok) + a.Equal(codes.NotFound, s.Code()) + }) + + t.Run("loadHandle", func(t *testing.T) { a := assert.New(t) - p2, err := rig.p.Load(ctx, p.ID) + p2, err := rig.p.Load(ctx, &LoadRequest{Kind: &LoadRequest_Email{Email: email}}) a.NoError(err) a.NotNil(p2) a.Truef(proto.Equal(p, p2), "%v vs. %v", p, p2) a.Equal(p.String(), p2.String()) }) + t.Run("loadHandle404", func(t *testing.T) { + a := assert.New(t) + _, err := rig.p.Load(ctx, &LoadRequest{Kind: &LoadRequest_Email{Email: "not_found@example.com"}}) + s, ok := status.FromError(err) + a.True(ok) + a.Equal(codes.NotFound, s.Code()) + }) + t.Run("partial-update", func(t *testing.T) { a := assert.New(t) resp, err := rig.p.Ensure(ctx, &EnsureRequest{ - Principal: &Principal{ID: p.ID, Label: "More Label", Version: 2}}) + Principal: &Principal{ID: p.ID, RefreshAfter: timestamppb.Now(), RefreshToken: "Updated Token", Version: 2}}) a.NoError(err) - a.Equal("More Label", resp.Principal.Label) - a.Equal(p.PasswordHash, resp.Principal.PasswordHash) - a.NotEmpty(resp.Principal.PasswordHash) + a.Equal("Updated Token", resp.Principal.RefreshToken) + a.Equal(p.Label, resp.Principal.Label) + a.Equal(int64(3), resp.Principal.Version) }) t.Run("skew", func(t *testing.T) { @@ -112,4 +144,27 @@ func TestPrincipal(t *testing.T) { a.True(errors.Is(err, util.ErrVersionSkew)) }) + t.Run("domain", func(t *testing.T) { + a := assert.New(t) + resp, err := rig.p.Ensure(ctx, &EnsureRequest{ + Principal: &Principal{EmailDomain: "Example.COM"}}) + if !a.NoError(err) { + return + } + a.Equal(int64(1), resp.Principal.Version) + + found, err := rig.p.Load(ctx, &LoadRequest{ + Kind: &LoadRequest_EmailDomain{EmailDomain: "example.com"}}) + if !a.NoError(err) { + return + } + a.Equal(resp.Principal.ID.String(), found.ID.String()) + a.Equal("example.com", found.EmailDomain) + + _, err = rig.p.Ensure(ctx, &EnsureRequest{ + Principal: &Principal{EmailDomain: "example.com"}}) + if a.NotNil(err) { + a.Contains(err.Error(), "duplicate key value") + } + }) } diff --git a/pkg/store/schema/schema.go b/pkg/store/schema/schema.go index 558f672..e66223f 100644 --- a/pkg/store/schema/schema.go +++ b/pkg/store/schema/schema.go @@ -124,18 +124,24 @@ CREATE TABLE IF NOT EXISTS files ( CREATE TABLE IF NOT EXISTS principals ( region STRING NOT NULL DEFAULT IFNULL(crdb_internal.locality_value('region'), 'global') CHECK (length(region)>0), principal UUID NOT NULL UNIQUE, - label STRING NOT NULL CHECK (length(label) > 0), - pw_hash STRING NOT NULL CHECK (length(pw_hash) > 0), + + -- A principal may be created to delegate access to all users within a given email domain. + email_domain STRING NOT NULL DEFAULT '', + + refresh_after TIMESTAMPTZ NOT NULL DEFAULT 0::TIMESTAMPTZ, -- The time at which the claims must be revalidated + refresh_status INT8 NOT NULL DEFAULT 0, -- Refresh state enum + refresh_token STRING NOT NULL DEFAULT '', -- OAuth2 refresh token to achieve revalidation + + -- We're going to store the entire OIDC claim block for future + -- reference and extract the well-known fields that we care about. + claims JSONB, + email STRING NOT NULL AS (lower(IFNULL(claims->>'email', ''))) STORED, + name STRING NOT NULL AS (IFNULL(claims->>'name', principal::string)) STORED, + version INT8 NOT NULL CHECK (version > 0), - PRIMARY KEY (region, principal) -) -`, ` -CREATE TABLE IF NOT EXISTS principal_handles ( - region STRING NOT NULL DEFAULT IFNULL(crdb_internal.locality_value('region'), 'global') CHECK (length(region)>0), - -- The globally-unique handle, e.g. email:you@example.com, sms:+15551231234 - urn STRING NOT NULL UNIQUE, - principal UUID NOT NULL REFERENCES principals (principal) ON DELETE CASCADE, - PRIMARY KEY (region, urn) + PRIMARY KEY (region, principal), + UNIQUE INDEX (email_domain) WHERE email_domain != '', + UNIQUE INDEX (email) WHERE email != '' ) `, ` CREATE TABLE IF NOT EXISTS sessions ( @@ -156,7 +162,7 @@ CREATE TABLE IF NOT EXISTS sessions ( PRIMARY KEY (region, session), INDEX (principal, tenant, path), - UNIQUE INDEX (principal, name) WHERE length(name) > 0 + UNIQUE INDEX (principal, name) WHERE name != '' ) `, ` CREATE TABLE IF NOT EXISTS uploads ( diff --git a/pkg/store/set.go b/pkg/store/set.go index 08c072c..3918c69 100644 --- a/pkg/store/set.go +++ b/pkg/store/set.go @@ -14,7 +14,6 @@ package store import ( - "github.com/bobvawter/cacheroach/pkg/store/auth" "github.com/bobvawter/cacheroach/pkg/store/blob" "github.com/bobvawter/cacheroach/pkg/store/fs" "github.com/bobvawter/cacheroach/pkg/store/principal" @@ -29,7 +28,6 @@ import ( // // Combine with storetesting.Set for a ready-to-run stack. var Set = wire.NewSet( - auth.Set, blob.Set, fs.Set, principal.Set, diff --git a/pkg/store/token/token.go b/pkg/store/token/token.go index 5d7ec5e..96c6dab 100644 --- a/pkg/store/token/token.go +++ b/pkg/store/token/token.go @@ -106,11 +106,15 @@ func (s *Server) Find(scope *session.Scope, server token.Tokens_FindServer) erro s.cache.Remove(cacheKey) } return util.RetryLoop(ctx, func(ctx context.Context, sideEffect *util.Marker) error { - rows, err := s.db.Query(ctx, - "SELECT session, tenant, path, capabilities, expires_at, note, name, super "+ - "FROM sessions "+ - "WHERE principal = $1 AND expires_at > now()", - sn.PrincipalId) + rows, err := s.db.Query(ctx, ` +WITH + dom AS (SELECT substring(email, '@(.*)$') as email_domain FROM principals WHERE principal = $1 AND email != ''), + prns AS (SELECT principal FROM principals JOIN dom USING (email_domain) UNION SELECT $1::UUID) +SELECT session, tenant, path, capabilities, expires_at, note, name, super +FROM sessions +JOIN prns USING (principal) +WHERE expires_at > now() +`, sn.PrincipalId) if err != nil { return err } @@ -393,6 +397,11 @@ func (s *Server) Validate(ctx context.Context, t *token.Token) (*session.Session return nil, nil } + // Ephemeral session; we'll see this with signed-requests. + if ret.ID.Zero() { + return ret, nil + } + if found, ok := s.cache.Get(ret.ID.AsUUID()); ok { v := found.(*cached) if v.expires.After(time.Now()) { @@ -401,22 +410,20 @@ func (s *Server) Validate(ctx context.Context, t *token.Token) (*session.Session s.cache.Remove(ret.ID.AsUUID()) } - if !ret.ID.Zero() { - if err := util.Retry(ctx, func(ctx context.Context) error { - var count int - err := s.db.QueryRow(ctx, - "SELECT count(*) "+ - "FROM sessions "+ - "WHERE session = $1 AND expires_at > now()", - ret.ID.AsUUID().String(), - ).Scan(&count) - if count == 0 { - ret = nil - } - return err - }); err != nil { - return nil, err + if err := util.Retry(ctx, func(ctx context.Context) error { + var count int + err := s.db.QueryRow(ctx, + "SELECT count(*) "+ + "FROM sessions "+ + "WHERE session = $1 AND expires_at > now()", + ret.ID.AsUUID().String(), + ).Scan(&count) + if count == 0 { + ret = nil } + return err + }); err != nil { + return nil, err } if ret != nil { diff --git a/pkg/store/token/token_test.go b/pkg/store/token/token_test.go index 13395c7..a2e512a 100644 --- a/pkg/store/token/token_test.go +++ b/pkg/store/token/token_test.go @@ -31,6 +31,101 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) +// Check that a domain-level principal implicitly delegates to +// other principals with the name email domain. +func TestDomainInheritance(t *testing.T) { + a := assert.New(t) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + rig, cleanup, err := testRig(ctx) + if !a.NoError(err) { + return + } + defer cleanup() + + tID := tenant.NewID() + _, err = rig.tenants.Ensure(ctx, &tenant.EnsureRequest{Tenant: &tenant.Tenant{ + Label: "Some Tenant", + ID: tID, + }}) + if !a.NoError(err) { + return + } + + pID := principal.NewID() + _, err = rig.principals.Ensure(ctx, &principal.EnsureRequest{ + Principal: &principal.Principal{ + ID: pID, + Claims: []byte(`{"email":"user@example.com"}`), + }}) + if !a.NoError(err) { + return + } + + principalSession, err := rig.tokens.Issue(ctx, &IssueRequest{Template: &session.Session{ + PrincipalId: pID, + ExpiresAt: timestamppb.New(time.Now().Add(time.Hour)), + Capabilities: capabilities.All(), + Scope: &session.Scope{Kind: &session.Scope_OnPrincipal{OnPrincipal: pID}}, + }}) + if !a.NoError(err) { + return + } + + domainID := principal.NewID() + _, err = rig.principals.Ensure(ctx, &principal.EnsureRequest{ + Principal: &principal.Principal{ + Label: "Domain Principal", + ID: domainID, + EmailDomain: "example.com", + }}) + if !a.NoError(err) { + return + } + + domainSession, err := rig.tokens.Issue(ctx, &IssueRequest{Template: &session.Session{ + PrincipalId: domainID, + ExpiresAt: timestamppb.New(time.Now().Add(time.Hour)), + Capabilities: capabilities.All(), + Scope: &session.Scope{ + Kind: &session.Scope_OnLocation{ + OnLocation: &session.Location{ + TenantId: tID, + Path: "/*", + }}}}}) + if !a.NoError(err) { + return + } + + { + sink := &sink{ctx: session.WithSession(ctx, principalSession.Issued)} + err = rig.tokens.Find(&session.Scope{}, sink) + if !a.NoError(err) { + return + } + a.Len(sink.ret, 2) + } + + // Ensure that the domain-level session can will be invalidated. + _, err = rig.tokens.Invalidate(session.WithSession(ctx, domainSession.Issued), + &InvalidateRequest{Kind: &InvalidateRequest_ID{ID: domainSession.Issued.ID}}) + if !a.NoError(err) { + return + } + + rig.tokens.cache.Purge() + + { + sink := &sink{ctx: session.WithSession(ctx, principalSession.Issued)} + err = rig.tokens.Find(&session.Scope{}, sink) + if !a.NoError(err) { + return + } + a.Len(sink.ret, 1) + } +} + func TestTokenFlow(t *testing.T) { a := assert.New(t) ctx, cancel := context.WithTimeout(context.Background(), time.Minute) @@ -47,16 +142,19 @@ func TestTokenFlow(t *testing.T) { Label: "Some Tenant", ID: tID, }}) - a.NoError(err) + if !a.NoError(err) { + return + } pID := principal.NewID() p := &principal.Principal{ Label: "Some User", ID: pID, } - a.NoError(p.SetPassword("")) _, err = rig.principals.Ensure(ctx, &principal.EnsureRequest{Principal: p}) - a.NoError(err) + if !a.NoError(err) { + return + } tcs := []*session.Session{ { @@ -188,7 +286,6 @@ func TestRefreshFlow(t *testing.T) { Label: "Some User", ID: pID, } - a.NoError(p.SetPassword("")) _, err = rig.principals.Ensure(ctx, &principal.EnsureRequest{Principal: p}) a.NoError(err) diff --git a/pkg/store/upload/upload_test.go b/pkg/store/upload/upload_test.go index 859ebeb..f31f6f0 100644 --- a/pkg/store/upload/upload_test.go +++ b/pkg/store/upload/upload_test.go @@ -43,9 +43,8 @@ func TestUploadFlow(t *testing.T) { pID := principal.NewID() if _, err := rig.principals.Ensure(ctx, &principal.EnsureRequest{ Principal: &principal.Principal{ - ID: pID, - Label: "User", - PasswordHash: " ", + ID: pID, + Label: "User", }}); !a.NoError(err) { return } diff --git a/tools.go b/tools.go index da56cae..f79f266 100644 --- a/tools.go +++ b/tools.go @@ -24,6 +24,6 @@ import ( _ "honnef.co/go/tools/cmd/staticcheck" ) -//go:generate protoc --go_out=./api/ --go_opt=module=github.com/bobvawter/cacheroach/api --go-grpc_out=./api/ --go-grpc_opt=module=github.com/bobvawter/cacheroach/api -I ./api/ auth.proto capabilities.proto diag.proto file.proto principal.proto session.proto tenant.proto token.proto upload.proto vhost.proto +//go:generate protoc --go_out=./api/ --go_opt=module=github.com/bobvawter/cacheroach/api --go-grpc_out=./api/ --go-grpc_opt=module=github.com/bobvawter/cacheroach/api -I ./api/ capabilities.proto diag.proto file.proto principal.proto session.proto tenant.proto token.proto upload.proto vhost.proto //go:generate go run github.com/google/wire/cmd/wire gen ./pkg/... //go:generate go run ./pkg/cmd/gendoc ./doc/