diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2af8910..7515fc9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,4 @@ { + "identity": "0.0.0", "secret": "0.0.0" } diff --git a/README.md b/README.md index 76fb380..65c1035 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Shared Go building blocks for Galaxy apps. Each module is versioned on its own. | Module | Description | | | --- | --- | --- | +| [`identity`](identity) | Authentication with Keycloak, Zitadel, or a trusted gateway. | [![Go Reference](https://pkg.go.dev/badge/github.com/galaxy-io/modules/identity.svg)](https://pkg.go.dev/github.com/galaxy-io/modules/identity) [![Version](https://img.shields.io/github/v/tag/galaxy-io/modules?filter=identity/*&label=version)](https://github.com/galaxy-io/modules/tags) | | [`secret`](secret) | Resolves secret refs to values, scoped per tenant. | [![Go Reference](https://pkg.go.dev/badge/github.com/galaxy-io/modules/secret.svg)](https://pkg.go.dev/github.com/galaxy-io/modules/secret) [![Version](https://img.shields.io/github/v/tag/galaxy-io/modules?filter=secret/*&label=version)](https://github.com/galaxy-io/modules/tags) | ## Install diff --git a/identity/auth/v1/authv1connect/service.connect.go b/identity/auth/v1/authv1connect/service.connect.go new file mode 100644 index 0000000..a3b5a13 --- /dev/null +++ b/identity/auth/v1/authv1connect/service.connect.go @@ -0,0 +1,519 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: auth/v1/service.proto + +package authv1connect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + v1 "github.com/galaxy-io/modules/identity/auth/v1" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // AuthServiceName is the fully-qualified name of the AuthService service. + AuthServiceName = "auth.v1.AuthService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // AuthServiceGetAuthConfigProcedure is the fully-qualified name of the AuthService's GetAuthConfig + // RPC. + AuthServiceGetAuthConfigProcedure = "/auth.v1.AuthService/GetAuthConfig" + // AuthServiceLoginProcedure is the fully-qualified name of the AuthService's Login RPC. + AuthServiceLoginProcedure = "/auth.v1.AuthService/Login" + // AuthServiceGetTokenProcedure is the fully-qualified name of the AuthService's GetToken RPC. + AuthServiceGetTokenProcedure = "/auth.v1.AuthService/GetToken" + // AuthServiceLogoutProcedure is the fully-qualified name of the AuthService's Logout RPC. + AuthServiceLogoutProcedure = "/auth.v1.AuthService/Logout" + // AuthServiceRegisterProcedure is the fully-qualified name of the AuthService's Register RPC. + AuthServiceRegisterProcedure = "/auth.v1.AuthService/Register" + // AuthServiceAcceptInviteProcedure is the fully-qualified name of the AuthService's AcceptInvite + // RPC. + AuthServiceAcceptInviteProcedure = "/auth.v1.AuthService/AcceptInvite" + // AuthServiceGetSessionProcedure is the fully-qualified name of the AuthService's GetSession RPC. + AuthServiceGetSessionProcedure = "/auth.v1.AuthService/GetSession" + // AuthServiceListMembersProcedure is the fully-qualified name of the AuthService's ListMembers RPC. + AuthServiceListMembersProcedure = "/auth.v1.AuthService/ListMembers" + // AuthServiceInviteMemberProcedure is the fully-qualified name of the AuthService's InviteMember + // RPC. + AuthServiceInviteMemberProcedure = "/auth.v1.AuthService/InviteMember" + // AuthServiceSetMemberRoleProcedure is the fully-qualified name of the AuthService's SetMemberRole + // RPC. + AuthServiceSetMemberRoleProcedure = "/auth.v1.AuthService/SetMemberRole" + // AuthServiceRemoveMemberProcedure is the fully-qualified name of the AuthService's RemoveMember + // RPC. + AuthServiceRemoveMemberProcedure = "/auth.v1.AuthService/RemoveMember" + // AuthServiceListServiceAccountsProcedure is the fully-qualified name of the AuthService's + // ListServiceAccounts RPC. + AuthServiceListServiceAccountsProcedure = "/auth.v1.AuthService/ListServiceAccounts" + // AuthServiceCreateServiceAccountProcedure is the fully-qualified name of the AuthService's + // CreateServiceAccount RPC. + AuthServiceCreateServiceAccountProcedure = "/auth.v1.AuthService/CreateServiceAccount" + // AuthServiceRotateServiceAccountSecretProcedure is the fully-qualified name of the AuthService's + // RotateServiceAccountSecret RPC. + AuthServiceRotateServiceAccountSecretProcedure = "/auth.v1.AuthService/RotateServiceAccountSecret" + // AuthServiceRemoveServiceAccountProcedure is the fully-qualified name of the AuthService's + // RemoveServiceAccount RPC. + AuthServiceRemoveServiceAccountProcedure = "/auth.v1.AuthService/RemoveServiceAccount" +) + +// AuthServiceClient is a client for the auth.v1.AuthService service. +type AuthServiceClient interface { + // Session; pre-token, public. An empty issuer means auth is disabled. + GetAuthConfig(context.Context, *connect.Request[v1.GetAuthConfigRequest]) (*connect.Response[v1.GetAuthConfigResponse], error) + Login(context.Context, *connect.Request[v1.LoginRequest]) (*connect.Response[v1.LoginResponse], error) + GetToken(context.Context, *connect.Request[v1.GetTokenRequest]) (*connect.Response[v1.GetTokenResponse], error) + Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) + Register(context.Context, *connect.Request[v1.RegisterRequest]) (*connect.Response[v1.RegisterResponse], error) + AcceptInvite(context.Context, *connect.Request[v1.AcceptInviteRequest]) (*connect.Response[v1.AcceptInviteResponse], error) + // Session; authenticated. The UI's sign-in check. + GetSession(context.Context, *connect.Request[v1.GetSessionRequest]) (*connect.Response[v1.GetSessionResponse], error) + // Members; authenticated tenant administration. + ListMembers(context.Context, *connect.Request[v1.ListMembersRequest]) (*connect.Response[v1.ListMembersResponse], error) + InviteMember(context.Context, *connect.Request[v1.InviteMemberRequest]) (*connect.Response[v1.InviteMemberResponse], error) + SetMemberRole(context.Context, *connect.Request[v1.SetMemberRoleRequest]) (*connect.Response[v1.SetMemberRoleResponse], error) + RemoveMember(context.Context, *connect.Request[v1.RemoveMemberRequest]) (*connect.Response[v1.RemoveMemberResponse], error) + // Service accounts; authenticated tenant administration. Secrets are only + // returned by create and rotate and cannot be retrieved later. + ListServiceAccounts(context.Context, *connect.Request[v1.ListServiceAccountsRequest]) (*connect.Response[v1.ListServiceAccountsResponse], error) + CreateServiceAccount(context.Context, *connect.Request[v1.CreateServiceAccountRequest]) (*connect.Response[v1.CreateServiceAccountResponse], error) + RotateServiceAccountSecret(context.Context, *connect.Request[v1.RotateServiceAccountSecretRequest]) (*connect.Response[v1.RotateServiceAccountSecretResponse], error) + RemoveServiceAccount(context.Context, *connect.Request[v1.RemoveServiceAccountRequest]) (*connect.Response[v1.RemoveServiceAccountResponse], error) +} + +// NewAuthServiceClient constructs a client for the auth.v1.AuthService service. By default, it uses +// the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewAuthServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) AuthServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + authServiceMethods := v1.File_auth_v1_service_proto.Services().ByName("AuthService").Methods() + return &authServiceClient{ + getAuthConfig: connect.NewClient[v1.GetAuthConfigRequest, v1.GetAuthConfigResponse]( + httpClient, + baseURL+AuthServiceGetAuthConfigProcedure, + connect.WithSchema(authServiceMethods.ByName("GetAuthConfig")), + connect.WithClientOptions(opts...), + ), + login: connect.NewClient[v1.LoginRequest, v1.LoginResponse]( + httpClient, + baseURL+AuthServiceLoginProcedure, + connect.WithSchema(authServiceMethods.ByName("Login")), + connect.WithClientOptions(opts...), + ), + getToken: connect.NewClient[v1.GetTokenRequest, v1.GetTokenResponse]( + httpClient, + baseURL+AuthServiceGetTokenProcedure, + connect.WithSchema(authServiceMethods.ByName("GetToken")), + connect.WithClientOptions(opts...), + ), + logout: connect.NewClient[v1.LogoutRequest, v1.LogoutResponse]( + httpClient, + baseURL+AuthServiceLogoutProcedure, + connect.WithSchema(authServiceMethods.ByName("Logout")), + connect.WithClientOptions(opts...), + ), + register: connect.NewClient[v1.RegisterRequest, v1.RegisterResponse]( + httpClient, + baseURL+AuthServiceRegisterProcedure, + connect.WithSchema(authServiceMethods.ByName("Register")), + connect.WithClientOptions(opts...), + ), + acceptInvite: connect.NewClient[v1.AcceptInviteRequest, v1.AcceptInviteResponse]( + httpClient, + baseURL+AuthServiceAcceptInviteProcedure, + connect.WithSchema(authServiceMethods.ByName("AcceptInvite")), + connect.WithClientOptions(opts...), + ), + getSession: connect.NewClient[v1.GetSessionRequest, v1.GetSessionResponse]( + httpClient, + baseURL+AuthServiceGetSessionProcedure, + connect.WithSchema(authServiceMethods.ByName("GetSession")), + connect.WithClientOptions(opts...), + ), + listMembers: connect.NewClient[v1.ListMembersRequest, v1.ListMembersResponse]( + httpClient, + baseURL+AuthServiceListMembersProcedure, + connect.WithSchema(authServiceMethods.ByName("ListMembers")), + connect.WithClientOptions(opts...), + ), + inviteMember: connect.NewClient[v1.InviteMemberRequest, v1.InviteMemberResponse]( + httpClient, + baseURL+AuthServiceInviteMemberProcedure, + connect.WithSchema(authServiceMethods.ByName("InviteMember")), + connect.WithClientOptions(opts...), + ), + setMemberRole: connect.NewClient[v1.SetMemberRoleRequest, v1.SetMemberRoleResponse]( + httpClient, + baseURL+AuthServiceSetMemberRoleProcedure, + connect.WithSchema(authServiceMethods.ByName("SetMemberRole")), + connect.WithClientOptions(opts...), + ), + removeMember: connect.NewClient[v1.RemoveMemberRequest, v1.RemoveMemberResponse]( + httpClient, + baseURL+AuthServiceRemoveMemberProcedure, + connect.WithSchema(authServiceMethods.ByName("RemoveMember")), + connect.WithClientOptions(opts...), + ), + listServiceAccounts: connect.NewClient[v1.ListServiceAccountsRequest, v1.ListServiceAccountsResponse]( + httpClient, + baseURL+AuthServiceListServiceAccountsProcedure, + connect.WithSchema(authServiceMethods.ByName("ListServiceAccounts")), + connect.WithClientOptions(opts...), + ), + createServiceAccount: connect.NewClient[v1.CreateServiceAccountRequest, v1.CreateServiceAccountResponse]( + httpClient, + baseURL+AuthServiceCreateServiceAccountProcedure, + connect.WithSchema(authServiceMethods.ByName("CreateServiceAccount")), + connect.WithClientOptions(opts...), + ), + rotateServiceAccountSecret: connect.NewClient[v1.RotateServiceAccountSecretRequest, v1.RotateServiceAccountSecretResponse]( + httpClient, + baseURL+AuthServiceRotateServiceAccountSecretProcedure, + connect.WithSchema(authServiceMethods.ByName("RotateServiceAccountSecret")), + connect.WithClientOptions(opts...), + ), + removeServiceAccount: connect.NewClient[v1.RemoveServiceAccountRequest, v1.RemoveServiceAccountResponse]( + httpClient, + baseURL+AuthServiceRemoveServiceAccountProcedure, + connect.WithSchema(authServiceMethods.ByName("RemoveServiceAccount")), + connect.WithClientOptions(opts...), + ), + } +} + +// authServiceClient implements AuthServiceClient. +type authServiceClient struct { + getAuthConfig *connect.Client[v1.GetAuthConfigRequest, v1.GetAuthConfigResponse] + login *connect.Client[v1.LoginRequest, v1.LoginResponse] + getToken *connect.Client[v1.GetTokenRequest, v1.GetTokenResponse] + logout *connect.Client[v1.LogoutRequest, v1.LogoutResponse] + register *connect.Client[v1.RegisterRequest, v1.RegisterResponse] + acceptInvite *connect.Client[v1.AcceptInviteRequest, v1.AcceptInviteResponse] + getSession *connect.Client[v1.GetSessionRequest, v1.GetSessionResponse] + listMembers *connect.Client[v1.ListMembersRequest, v1.ListMembersResponse] + inviteMember *connect.Client[v1.InviteMemberRequest, v1.InviteMemberResponse] + setMemberRole *connect.Client[v1.SetMemberRoleRequest, v1.SetMemberRoleResponse] + removeMember *connect.Client[v1.RemoveMemberRequest, v1.RemoveMemberResponse] + listServiceAccounts *connect.Client[v1.ListServiceAccountsRequest, v1.ListServiceAccountsResponse] + createServiceAccount *connect.Client[v1.CreateServiceAccountRequest, v1.CreateServiceAccountResponse] + rotateServiceAccountSecret *connect.Client[v1.RotateServiceAccountSecretRequest, v1.RotateServiceAccountSecretResponse] + removeServiceAccount *connect.Client[v1.RemoveServiceAccountRequest, v1.RemoveServiceAccountResponse] +} + +// GetAuthConfig calls auth.v1.AuthService.GetAuthConfig. +func (c *authServiceClient) GetAuthConfig(ctx context.Context, req *connect.Request[v1.GetAuthConfigRequest]) (*connect.Response[v1.GetAuthConfigResponse], error) { + return c.getAuthConfig.CallUnary(ctx, req) +} + +// Login calls auth.v1.AuthService.Login. +func (c *authServiceClient) Login(ctx context.Context, req *connect.Request[v1.LoginRequest]) (*connect.Response[v1.LoginResponse], error) { + return c.login.CallUnary(ctx, req) +} + +// GetToken calls auth.v1.AuthService.GetToken. +func (c *authServiceClient) GetToken(ctx context.Context, req *connect.Request[v1.GetTokenRequest]) (*connect.Response[v1.GetTokenResponse], error) { + return c.getToken.CallUnary(ctx, req) +} + +// Logout calls auth.v1.AuthService.Logout. +func (c *authServiceClient) Logout(ctx context.Context, req *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) { + return c.logout.CallUnary(ctx, req) +} + +// Register calls auth.v1.AuthService.Register. +func (c *authServiceClient) Register(ctx context.Context, req *connect.Request[v1.RegisterRequest]) (*connect.Response[v1.RegisterResponse], error) { + return c.register.CallUnary(ctx, req) +} + +// AcceptInvite calls auth.v1.AuthService.AcceptInvite. +func (c *authServiceClient) AcceptInvite(ctx context.Context, req *connect.Request[v1.AcceptInviteRequest]) (*connect.Response[v1.AcceptInviteResponse], error) { + return c.acceptInvite.CallUnary(ctx, req) +} + +// GetSession calls auth.v1.AuthService.GetSession. +func (c *authServiceClient) GetSession(ctx context.Context, req *connect.Request[v1.GetSessionRequest]) (*connect.Response[v1.GetSessionResponse], error) { + return c.getSession.CallUnary(ctx, req) +} + +// ListMembers calls auth.v1.AuthService.ListMembers. +func (c *authServiceClient) ListMembers(ctx context.Context, req *connect.Request[v1.ListMembersRequest]) (*connect.Response[v1.ListMembersResponse], error) { + return c.listMembers.CallUnary(ctx, req) +} + +// InviteMember calls auth.v1.AuthService.InviteMember. +func (c *authServiceClient) InviteMember(ctx context.Context, req *connect.Request[v1.InviteMemberRequest]) (*connect.Response[v1.InviteMemberResponse], error) { + return c.inviteMember.CallUnary(ctx, req) +} + +// SetMemberRole calls auth.v1.AuthService.SetMemberRole. +func (c *authServiceClient) SetMemberRole(ctx context.Context, req *connect.Request[v1.SetMemberRoleRequest]) (*connect.Response[v1.SetMemberRoleResponse], error) { + return c.setMemberRole.CallUnary(ctx, req) +} + +// RemoveMember calls auth.v1.AuthService.RemoveMember. +func (c *authServiceClient) RemoveMember(ctx context.Context, req *connect.Request[v1.RemoveMemberRequest]) (*connect.Response[v1.RemoveMemberResponse], error) { + return c.removeMember.CallUnary(ctx, req) +} + +// ListServiceAccounts calls auth.v1.AuthService.ListServiceAccounts. +func (c *authServiceClient) ListServiceAccounts(ctx context.Context, req *connect.Request[v1.ListServiceAccountsRequest]) (*connect.Response[v1.ListServiceAccountsResponse], error) { + return c.listServiceAccounts.CallUnary(ctx, req) +} + +// CreateServiceAccount calls auth.v1.AuthService.CreateServiceAccount. +func (c *authServiceClient) CreateServiceAccount(ctx context.Context, req *connect.Request[v1.CreateServiceAccountRequest]) (*connect.Response[v1.CreateServiceAccountResponse], error) { + return c.createServiceAccount.CallUnary(ctx, req) +} + +// RotateServiceAccountSecret calls auth.v1.AuthService.RotateServiceAccountSecret. +func (c *authServiceClient) RotateServiceAccountSecret(ctx context.Context, req *connect.Request[v1.RotateServiceAccountSecretRequest]) (*connect.Response[v1.RotateServiceAccountSecretResponse], error) { + return c.rotateServiceAccountSecret.CallUnary(ctx, req) +} + +// RemoveServiceAccount calls auth.v1.AuthService.RemoveServiceAccount. +func (c *authServiceClient) RemoveServiceAccount(ctx context.Context, req *connect.Request[v1.RemoveServiceAccountRequest]) (*connect.Response[v1.RemoveServiceAccountResponse], error) { + return c.removeServiceAccount.CallUnary(ctx, req) +} + +// AuthServiceHandler is an implementation of the auth.v1.AuthService service. +type AuthServiceHandler interface { + // Session; pre-token, public. An empty issuer means auth is disabled. + GetAuthConfig(context.Context, *connect.Request[v1.GetAuthConfigRequest]) (*connect.Response[v1.GetAuthConfigResponse], error) + Login(context.Context, *connect.Request[v1.LoginRequest]) (*connect.Response[v1.LoginResponse], error) + GetToken(context.Context, *connect.Request[v1.GetTokenRequest]) (*connect.Response[v1.GetTokenResponse], error) + Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) + Register(context.Context, *connect.Request[v1.RegisterRequest]) (*connect.Response[v1.RegisterResponse], error) + AcceptInvite(context.Context, *connect.Request[v1.AcceptInviteRequest]) (*connect.Response[v1.AcceptInviteResponse], error) + // Session; authenticated. The UI's sign-in check. + GetSession(context.Context, *connect.Request[v1.GetSessionRequest]) (*connect.Response[v1.GetSessionResponse], error) + // Members; authenticated tenant administration. + ListMembers(context.Context, *connect.Request[v1.ListMembersRequest]) (*connect.Response[v1.ListMembersResponse], error) + InviteMember(context.Context, *connect.Request[v1.InviteMemberRequest]) (*connect.Response[v1.InviteMemberResponse], error) + SetMemberRole(context.Context, *connect.Request[v1.SetMemberRoleRequest]) (*connect.Response[v1.SetMemberRoleResponse], error) + RemoveMember(context.Context, *connect.Request[v1.RemoveMemberRequest]) (*connect.Response[v1.RemoveMemberResponse], error) + // Service accounts; authenticated tenant administration. Secrets are only + // returned by create and rotate and cannot be retrieved later. + ListServiceAccounts(context.Context, *connect.Request[v1.ListServiceAccountsRequest]) (*connect.Response[v1.ListServiceAccountsResponse], error) + CreateServiceAccount(context.Context, *connect.Request[v1.CreateServiceAccountRequest]) (*connect.Response[v1.CreateServiceAccountResponse], error) + RotateServiceAccountSecret(context.Context, *connect.Request[v1.RotateServiceAccountSecretRequest]) (*connect.Response[v1.RotateServiceAccountSecretResponse], error) + RemoveServiceAccount(context.Context, *connect.Request[v1.RemoveServiceAccountRequest]) (*connect.Response[v1.RemoveServiceAccountResponse], error) +} + +// NewAuthServiceHandler builds an HTTP handler from the service implementation. It returns the path +// on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewAuthServiceHandler(svc AuthServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + authServiceMethods := v1.File_auth_v1_service_proto.Services().ByName("AuthService").Methods() + authServiceGetAuthConfigHandler := connect.NewUnaryHandler( + AuthServiceGetAuthConfigProcedure, + svc.GetAuthConfig, + connect.WithSchema(authServiceMethods.ByName("GetAuthConfig")), + connect.WithHandlerOptions(opts...), + ) + authServiceLoginHandler := connect.NewUnaryHandler( + AuthServiceLoginProcedure, + svc.Login, + connect.WithSchema(authServiceMethods.ByName("Login")), + connect.WithHandlerOptions(opts...), + ) + authServiceGetTokenHandler := connect.NewUnaryHandler( + AuthServiceGetTokenProcedure, + svc.GetToken, + connect.WithSchema(authServiceMethods.ByName("GetToken")), + connect.WithHandlerOptions(opts...), + ) + authServiceLogoutHandler := connect.NewUnaryHandler( + AuthServiceLogoutProcedure, + svc.Logout, + connect.WithSchema(authServiceMethods.ByName("Logout")), + connect.WithHandlerOptions(opts...), + ) + authServiceRegisterHandler := connect.NewUnaryHandler( + AuthServiceRegisterProcedure, + svc.Register, + connect.WithSchema(authServiceMethods.ByName("Register")), + connect.WithHandlerOptions(opts...), + ) + authServiceAcceptInviteHandler := connect.NewUnaryHandler( + AuthServiceAcceptInviteProcedure, + svc.AcceptInvite, + connect.WithSchema(authServiceMethods.ByName("AcceptInvite")), + connect.WithHandlerOptions(opts...), + ) + authServiceGetSessionHandler := connect.NewUnaryHandler( + AuthServiceGetSessionProcedure, + svc.GetSession, + connect.WithSchema(authServiceMethods.ByName("GetSession")), + connect.WithHandlerOptions(opts...), + ) + authServiceListMembersHandler := connect.NewUnaryHandler( + AuthServiceListMembersProcedure, + svc.ListMembers, + connect.WithSchema(authServiceMethods.ByName("ListMembers")), + connect.WithHandlerOptions(opts...), + ) + authServiceInviteMemberHandler := connect.NewUnaryHandler( + AuthServiceInviteMemberProcedure, + svc.InviteMember, + connect.WithSchema(authServiceMethods.ByName("InviteMember")), + connect.WithHandlerOptions(opts...), + ) + authServiceSetMemberRoleHandler := connect.NewUnaryHandler( + AuthServiceSetMemberRoleProcedure, + svc.SetMemberRole, + connect.WithSchema(authServiceMethods.ByName("SetMemberRole")), + connect.WithHandlerOptions(opts...), + ) + authServiceRemoveMemberHandler := connect.NewUnaryHandler( + AuthServiceRemoveMemberProcedure, + svc.RemoveMember, + connect.WithSchema(authServiceMethods.ByName("RemoveMember")), + connect.WithHandlerOptions(opts...), + ) + authServiceListServiceAccountsHandler := connect.NewUnaryHandler( + AuthServiceListServiceAccountsProcedure, + svc.ListServiceAccounts, + connect.WithSchema(authServiceMethods.ByName("ListServiceAccounts")), + connect.WithHandlerOptions(opts...), + ) + authServiceCreateServiceAccountHandler := connect.NewUnaryHandler( + AuthServiceCreateServiceAccountProcedure, + svc.CreateServiceAccount, + connect.WithSchema(authServiceMethods.ByName("CreateServiceAccount")), + connect.WithHandlerOptions(opts...), + ) + authServiceRotateServiceAccountSecretHandler := connect.NewUnaryHandler( + AuthServiceRotateServiceAccountSecretProcedure, + svc.RotateServiceAccountSecret, + connect.WithSchema(authServiceMethods.ByName("RotateServiceAccountSecret")), + connect.WithHandlerOptions(opts...), + ) + authServiceRemoveServiceAccountHandler := connect.NewUnaryHandler( + AuthServiceRemoveServiceAccountProcedure, + svc.RemoveServiceAccount, + connect.WithSchema(authServiceMethods.ByName("RemoveServiceAccount")), + connect.WithHandlerOptions(opts...), + ) + return "/auth.v1.AuthService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case AuthServiceGetAuthConfigProcedure: + authServiceGetAuthConfigHandler.ServeHTTP(w, r) + case AuthServiceLoginProcedure: + authServiceLoginHandler.ServeHTTP(w, r) + case AuthServiceGetTokenProcedure: + authServiceGetTokenHandler.ServeHTTP(w, r) + case AuthServiceLogoutProcedure: + authServiceLogoutHandler.ServeHTTP(w, r) + case AuthServiceRegisterProcedure: + authServiceRegisterHandler.ServeHTTP(w, r) + case AuthServiceAcceptInviteProcedure: + authServiceAcceptInviteHandler.ServeHTTP(w, r) + case AuthServiceGetSessionProcedure: + authServiceGetSessionHandler.ServeHTTP(w, r) + case AuthServiceListMembersProcedure: + authServiceListMembersHandler.ServeHTTP(w, r) + case AuthServiceInviteMemberProcedure: + authServiceInviteMemberHandler.ServeHTTP(w, r) + case AuthServiceSetMemberRoleProcedure: + authServiceSetMemberRoleHandler.ServeHTTP(w, r) + case AuthServiceRemoveMemberProcedure: + authServiceRemoveMemberHandler.ServeHTTP(w, r) + case AuthServiceListServiceAccountsProcedure: + authServiceListServiceAccountsHandler.ServeHTTP(w, r) + case AuthServiceCreateServiceAccountProcedure: + authServiceCreateServiceAccountHandler.ServeHTTP(w, r) + case AuthServiceRotateServiceAccountSecretProcedure: + authServiceRotateServiceAccountSecretHandler.ServeHTTP(w, r) + case AuthServiceRemoveServiceAccountProcedure: + authServiceRemoveServiceAccountHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedAuthServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedAuthServiceHandler struct{} + +func (UnimplementedAuthServiceHandler) GetAuthConfig(context.Context, *connect.Request[v1.GetAuthConfigRequest]) (*connect.Response[v1.GetAuthConfigResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.GetAuthConfig is not implemented")) +} + +func (UnimplementedAuthServiceHandler) Login(context.Context, *connect.Request[v1.LoginRequest]) (*connect.Response[v1.LoginResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.Login is not implemented")) +} + +func (UnimplementedAuthServiceHandler) GetToken(context.Context, *connect.Request[v1.GetTokenRequest]) (*connect.Response[v1.GetTokenResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.GetToken is not implemented")) +} + +func (UnimplementedAuthServiceHandler) Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.Logout is not implemented")) +} + +func (UnimplementedAuthServiceHandler) Register(context.Context, *connect.Request[v1.RegisterRequest]) (*connect.Response[v1.RegisterResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.Register is not implemented")) +} + +func (UnimplementedAuthServiceHandler) AcceptInvite(context.Context, *connect.Request[v1.AcceptInviteRequest]) (*connect.Response[v1.AcceptInviteResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.AcceptInvite is not implemented")) +} + +func (UnimplementedAuthServiceHandler) GetSession(context.Context, *connect.Request[v1.GetSessionRequest]) (*connect.Response[v1.GetSessionResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.GetSession is not implemented")) +} + +func (UnimplementedAuthServiceHandler) ListMembers(context.Context, *connect.Request[v1.ListMembersRequest]) (*connect.Response[v1.ListMembersResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.ListMembers is not implemented")) +} + +func (UnimplementedAuthServiceHandler) InviteMember(context.Context, *connect.Request[v1.InviteMemberRequest]) (*connect.Response[v1.InviteMemberResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.InviteMember is not implemented")) +} + +func (UnimplementedAuthServiceHandler) SetMemberRole(context.Context, *connect.Request[v1.SetMemberRoleRequest]) (*connect.Response[v1.SetMemberRoleResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.SetMemberRole is not implemented")) +} + +func (UnimplementedAuthServiceHandler) RemoveMember(context.Context, *connect.Request[v1.RemoveMemberRequest]) (*connect.Response[v1.RemoveMemberResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.RemoveMember is not implemented")) +} + +func (UnimplementedAuthServiceHandler) ListServiceAccounts(context.Context, *connect.Request[v1.ListServiceAccountsRequest]) (*connect.Response[v1.ListServiceAccountsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.ListServiceAccounts is not implemented")) +} + +func (UnimplementedAuthServiceHandler) CreateServiceAccount(context.Context, *connect.Request[v1.CreateServiceAccountRequest]) (*connect.Response[v1.CreateServiceAccountResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.CreateServiceAccount is not implemented")) +} + +func (UnimplementedAuthServiceHandler) RotateServiceAccountSecret(context.Context, *connect.Request[v1.RotateServiceAccountSecretRequest]) (*connect.Response[v1.RotateServiceAccountSecretResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.RotateServiceAccountSecret is not implemented")) +} + +func (UnimplementedAuthServiceHandler) RemoveServiceAccount(context.Context, *connect.Request[v1.RemoveServiceAccountRequest]) (*connect.Response[v1.RemoveServiceAccountResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.RemoveServiceAccount is not implemented")) +} diff --git a/identity/auth/v1/members.pb.go b/identity/auth/v1/members.pb.go new file mode 100644 index 0000000..e0e54fa --- /dev/null +++ b/identity/auth/v1/members.pb.go @@ -0,0 +1,628 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: auth/v1/members.proto + +package authv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Role is an app permission level. The vocabulary is the app's, not the +// provider's: an adapter maps whatever grants it stores onto these. +type Role int32 + +const ( + Role_ROLE_UNSPECIFIED Role = 0 + Role_ROLE_ADMIN Role = 1 + Role_ROLE_CREATOR Role = 2 + Role_ROLE_VIEWER Role = 3 +) + +// Enum value maps for Role. +var ( + Role_name = map[int32]string{ + 0: "ROLE_UNSPECIFIED", + 1: "ROLE_ADMIN", + 2: "ROLE_CREATOR", + 3: "ROLE_VIEWER", + } + Role_value = map[string]int32{ + "ROLE_UNSPECIFIED": 0, + "ROLE_ADMIN": 1, + "ROLE_CREATOR": 2, + "ROLE_VIEWER": 3, + } +) + +func (x Role) Enum() *Role { + p := new(Role) + *p = x + return p +} + +func (x Role) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Role) Descriptor() protoreflect.EnumDescriptor { + return file_auth_v1_members_proto_enumTypes[0].Descriptor() +} + +func (Role) Type() protoreflect.EnumType { + return &file_auth_v1_members_proto_enumTypes[0] +} + +func (x Role) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Role.Descriptor instead. +func (Role) EnumDescriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{0} +} + +// Member is one user of a tenant. +type Member struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` + Role Role `protobuf:"varint,4,opt,name=role,proto3,enum=auth.v1.Role" json:"role,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Member) Reset() { + *x = Member{} + mi := &file_auth_v1_members_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Member) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Member) ProtoMessage() {} + +func (x *Member) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Member.ProtoReflect.Descriptor instead. +func (*Member) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{0} +} + +func (x *Member) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *Member) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Member) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *Member) GetRole() Role { + if x != nil { + return x.Role + } + return Role_ROLE_UNSPECIFIED +} + +type ListMembersRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListMembersRequest) Reset() { + *x = ListMembersRequest{} + mi := &file_auth_v1_members_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListMembersRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListMembersRequest) ProtoMessage() {} + +func (x *ListMembersRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListMembersRequest.ProtoReflect.Descriptor instead. +func (*ListMembersRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{1} +} + +type ListMembersResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Members []*Member `protobuf:"bytes,1,rep,name=members,proto3" json:"members,omitempty"` + // can_manage reports whether the caller may invite, re-role, or remove. + // The UI renders read-only when false. + CanManage bool `protobuf:"varint,2,opt,name=can_manage,json=canManage,proto3" json:"can_manage,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListMembersResponse) Reset() { + *x = ListMembersResponse{} + mi := &file_auth_v1_members_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListMembersResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListMembersResponse) ProtoMessage() {} + +func (x *ListMembersResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListMembersResponse.ProtoReflect.Descriptor instead. +func (*ListMembersResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{2} +} + +func (x *ListMembersResponse) GetMembers() []*Member { + if x != nil { + return x.Members + } + return nil +} + +func (x *ListMembersResponse) GetCanManage() bool { + if x != nil { + return x.CanManage + } + return false +} + +type InviteMemberRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + GivenName string `protobuf:"bytes,2,opt,name=given_name,json=givenName,proto3" json:"given_name,omitempty"` + FamilyName string `protobuf:"bytes,3,opt,name=family_name,json=familyName,proto3" json:"family_name,omitempty"` + Role Role `protobuf:"varint,4,opt,name=role,proto3,enum=auth.v1.Role" json:"role,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InviteMemberRequest) Reset() { + *x = InviteMemberRequest{} + mi := &file_auth_v1_members_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InviteMemberRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InviteMemberRequest) ProtoMessage() {} + +func (x *InviteMemberRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InviteMemberRequest.ProtoReflect.Descriptor instead. +func (*InviteMemberRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{3} +} + +func (x *InviteMemberRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *InviteMemberRequest) GetGivenName() string { + if x != nil { + return x.GivenName + } + return "" +} + +func (x *InviteMemberRequest) GetFamilyName() string { + if x != nil { + return x.FamilyName + } + return "" +} + +func (x *InviteMemberRequest) GetRole() Role { + if x != nil { + return x.Role + } + return Role_ROLE_UNSPECIFIED +} + +// InviteMemberResponse carries the invitation an admin shares out of band. +// Nothing is emailed. +type InviteMemberResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Code string `protobuf:"bytes,2,opt,name=code,proto3" json:"code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InviteMemberResponse) Reset() { + *x = InviteMemberResponse{} + mi := &file_auth_v1_members_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InviteMemberResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InviteMemberResponse) ProtoMessage() {} + +func (x *InviteMemberResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InviteMemberResponse.ProtoReflect.Descriptor instead. +func (*InviteMemberResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{4} +} + +func (x *InviteMemberResponse) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *InviteMemberResponse) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +type SetMemberRoleRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Role Role `protobuf:"varint,2,opt,name=role,proto3,enum=auth.v1.Role" json:"role,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetMemberRoleRequest) Reset() { + *x = SetMemberRoleRequest{} + mi := &file_auth_v1_members_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetMemberRoleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetMemberRoleRequest) ProtoMessage() {} + +func (x *SetMemberRoleRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetMemberRoleRequest.ProtoReflect.Descriptor instead. +func (*SetMemberRoleRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{5} +} + +func (x *SetMemberRoleRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *SetMemberRoleRequest) GetRole() Role { + if x != nil { + return x.Role + } + return Role_ROLE_UNSPECIFIED +} + +type SetMemberRoleResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetMemberRoleResponse) Reset() { + *x = SetMemberRoleResponse{} + mi := &file_auth_v1_members_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetMemberRoleResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetMemberRoleResponse) ProtoMessage() {} + +func (x *SetMemberRoleResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetMemberRoleResponse.ProtoReflect.Descriptor instead. +func (*SetMemberRoleResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{6} +} + +type RemoveMemberRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveMemberRequest) Reset() { + *x = RemoveMemberRequest{} + mi := &file_auth_v1_members_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveMemberRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveMemberRequest) ProtoMessage() {} + +func (x *RemoveMemberRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveMemberRequest.ProtoReflect.Descriptor instead. +func (*RemoveMemberRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{7} +} + +func (x *RemoveMemberRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +type RemoveMemberResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveMemberResponse) Reset() { + *x = RemoveMemberResponse{} + mi := &file_auth_v1_members_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveMemberResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveMemberResponse) ProtoMessage() {} + +func (x *RemoveMemberResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveMemberResponse.ProtoReflect.Descriptor instead. +func (*RemoveMemberResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{8} +} + +var File_auth_v1_members_proto protoreflect.FileDescriptor + +const file_auth_v1_members_proto_rawDesc = "" + + "\n" + + "\x15auth/v1/members.proto\x12\aauth.v1\"n\n" + + "\x06Member\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" + + "\x05email\x18\x03 \x01(\tR\x05email\x12!\n" + + "\x04role\x18\x04 \x01(\x0e2\r.auth.v1.RoleR\x04role\"\x14\n" + + "\x12ListMembersRequest\"_\n" + + "\x13ListMembersResponse\x12)\n" + + "\amembers\x18\x01 \x03(\v2\x0f.auth.v1.MemberR\amembers\x12\x1d\n" + + "\n" + + "can_manage\x18\x02 \x01(\bR\tcanManage\"\x8e\x01\n" + + "\x13InviteMemberRequest\x12\x14\n" + + "\x05email\x18\x01 \x01(\tR\x05email\x12\x1d\n" + + "\n" + + "given_name\x18\x02 \x01(\tR\tgivenName\x12\x1f\n" + + "\vfamily_name\x18\x03 \x01(\tR\n" + + "familyName\x12!\n" + + "\x04role\x18\x04 \x01(\x0e2\r.auth.v1.RoleR\x04role\"C\n" + + "\x14InviteMemberResponse\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x12\n" + + "\x04code\x18\x02 \x01(\tR\x04code\"R\n" + + "\x14SetMemberRoleRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12!\n" + + "\x04role\x18\x02 \x01(\x0e2\r.auth.v1.RoleR\x04role\"\x17\n" + + "\x15SetMemberRoleResponse\".\n" + + "\x13RemoveMemberRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\"\x16\n" + + "\x14RemoveMemberResponse*O\n" + + "\x04Role\x12\x14\n" + + "\x10ROLE_UNSPECIFIED\x10\x00\x12\x0e\n" + + "\n" + + "ROLE_ADMIN\x10\x01\x12\x10\n" + + "\fROLE_CREATOR\x10\x02\x12\x0f\n" + + "\vROLE_VIEWER\x10\x03B\x8e\x01\n" + + "\vcom.auth.v1B\fMembersProtoP\x01Z4github.com/galaxy-io/modules/identity/auth/v1;authv1\xa2\x02\x03AXX\xaa\x02\aAuth.V1\xca\x02\aAuth\\V1\xe2\x02\x13Auth\\V1\\GPBMetadata\xea\x02\bAuth::V1b\x06proto3" + +var ( + file_auth_v1_members_proto_rawDescOnce sync.Once + file_auth_v1_members_proto_rawDescData []byte +) + +func file_auth_v1_members_proto_rawDescGZIP() []byte { + file_auth_v1_members_proto_rawDescOnce.Do(func() { + file_auth_v1_members_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_auth_v1_members_proto_rawDesc), len(file_auth_v1_members_proto_rawDesc))) + }) + return file_auth_v1_members_proto_rawDescData +} + +var file_auth_v1_members_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_auth_v1_members_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_auth_v1_members_proto_goTypes = []any{ + (Role)(0), // 0: auth.v1.Role + (*Member)(nil), // 1: auth.v1.Member + (*ListMembersRequest)(nil), // 2: auth.v1.ListMembersRequest + (*ListMembersResponse)(nil), // 3: auth.v1.ListMembersResponse + (*InviteMemberRequest)(nil), // 4: auth.v1.InviteMemberRequest + (*InviteMemberResponse)(nil), // 5: auth.v1.InviteMemberResponse + (*SetMemberRoleRequest)(nil), // 6: auth.v1.SetMemberRoleRequest + (*SetMemberRoleResponse)(nil), // 7: auth.v1.SetMemberRoleResponse + (*RemoveMemberRequest)(nil), // 8: auth.v1.RemoveMemberRequest + (*RemoveMemberResponse)(nil), // 9: auth.v1.RemoveMemberResponse +} +var file_auth_v1_members_proto_depIdxs = []int32{ + 0, // 0: auth.v1.Member.role:type_name -> auth.v1.Role + 1, // 1: auth.v1.ListMembersResponse.members:type_name -> auth.v1.Member + 0, // 2: auth.v1.InviteMemberRequest.role:type_name -> auth.v1.Role + 0, // 3: auth.v1.SetMemberRoleRequest.role:type_name -> auth.v1.Role + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_auth_v1_members_proto_init() } +func file_auth_v1_members_proto_init() { + if File_auth_v1_members_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_auth_v1_members_proto_rawDesc), len(file_auth_v1_members_proto_rawDesc)), + NumEnums: 1, + NumMessages: 9, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_auth_v1_members_proto_goTypes, + DependencyIndexes: file_auth_v1_members_proto_depIdxs, + EnumInfos: file_auth_v1_members_proto_enumTypes, + MessageInfos: file_auth_v1_members_proto_msgTypes, + }.Build() + File_auth_v1_members_proto = out.File + file_auth_v1_members_proto_goTypes = nil + file_auth_v1_members_proto_depIdxs = nil +} diff --git a/identity/auth/v1/service.pb.go b/identity/auth/v1/service.pb.go new file mode 100644 index 0000000..437b40a --- /dev/null +++ b/identity/auth/v1/service.pb.go @@ -0,0 +1,141 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: auth/v1/service.proto + +package authv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_auth_v1_service_proto protoreflect.FileDescriptor + +const file_auth_v1_service_proto_rawDesc = "" + + "\n" + + "\x15auth/v1/service.proto\x12\aauth.v1\x1a\x15auth/v1/members.proto\x1a\x1eauth/v1/service_accounts.proto\x1a\x15auth/v1/session.proto2\xbd\t\n" + + "\vAuthService\x12N\n" + + "\rGetAuthConfig\x12\x1d.auth.v1.GetAuthConfigRequest\x1a\x1e.auth.v1.GetAuthConfigResponse\x126\n" + + "\x05Login\x12\x15.auth.v1.LoginRequest\x1a\x16.auth.v1.LoginResponse\x12?\n" + + "\bGetToken\x12\x18.auth.v1.GetTokenRequest\x1a\x19.auth.v1.GetTokenResponse\x129\n" + + "\x06Logout\x12\x16.auth.v1.LogoutRequest\x1a\x17.auth.v1.LogoutResponse\x12?\n" + + "\bRegister\x12\x18.auth.v1.RegisterRequest\x1a\x19.auth.v1.RegisterResponse\x12K\n" + + "\fAcceptInvite\x12\x1c.auth.v1.AcceptInviteRequest\x1a\x1d.auth.v1.AcceptInviteResponse\x12E\n" + + "\n" + + "GetSession\x12\x1a.auth.v1.GetSessionRequest\x1a\x1b.auth.v1.GetSessionResponse\x12H\n" + + "\vListMembers\x12\x1b.auth.v1.ListMembersRequest\x1a\x1c.auth.v1.ListMembersResponse\x12K\n" + + "\fInviteMember\x12\x1c.auth.v1.InviteMemberRequest\x1a\x1d.auth.v1.InviteMemberResponse\x12N\n" + + "\rSetMemberRole\x12\x1d.auth.v1.SetMemberRoleRequest\x1a\x1e.auth.v1.SetMemberRoleResponse\x12K\n" + + "\fRemoveMember\x12\x1c.auth.v1.RemoveMemberRequest\x1a\x1d.auth.v1.RemoveMemberResponse\x12`\n" + + "\x13ListServiceAccounts\x12#.auth.v1.ListServiceAccountsRequest\x1a$.auth.v1.ListServiceAccountsResponse\x12c\n" + + "\x14CreateServiceAccount\x12$.auth.v1.CreateServiceAccountRequest\x1a%.auth.v1.CreateServiceAccountResponse\x12u\n" + + "\x1aRotateServiceAccountSecret\x12*.auth.v1.RotateServiceAccountSecretRequest\x1a+.auth.v1.RotateServiceAccountSecretResponse\x12c\n" + + "\x14RemoveServiceAccount\x12$.auth.v1.RemoveServiceAccountRequest\x1a%.auth.v1.RemoveServiceAccountResponseB\x8e\x01\n" + + "\vcom.auth.v1B\fServiceProtoP\x01Z4github.com/galaxy-io/modules/identity/auth/v1;authv1\xa2\x02\x03AXX\xaa\x02\aAuth.V1\xca\x02\aAuth\\V1\xe2\x02\x13Auth\\V1\\GPBMetadata\xea\x02\bAuth::V1b\x06proto3" + +var file_auth_v1_service_proto_goTypes = []any{ + (*GetAuthConfigRequest)(nil), // 0: auth.v1.GetAuthConfigRequest + (*LoginRequest)(nil), // 1: auth.v1.LoginRequest + (*GetTokenRequest)(nil), // 2: auth.v1.GetTokenRequest + (*LogoutRequest)(nil), // 3: auth.v1.LogoutRequest + (*RegisterRequest)(nil), // 4: auth.v1.RegisterRequest + (*AcceptInviteRequest)(nil), // 5: auth.v1.AcceptInviteRequest + (*GetSessionRequest)(nil), // 6: auth.v1.GetSessionRequest + (*ListMembersRequest)(nil), // 7: auth.v1.ListMembersRequest + (*InviteMemberRequest)(nil), // 8: auth.v1.InviteMemberRequest + (*SetMemberRoleRequest)(nil), // 9: auth.v1.SetMemberRoleRequest + (*RemoveMemberRequest)(nil), // 10: auth.v1.RemoveMemberRequest + (*ListServiceAccountsRequest)(nil), // 11: auth.v1.ListServiceAccountsRequest + (*CreateServiceAccountRequest)(nil), // 12: auth.v1.CreateServiceAccountRequest + (*RotateServiceAccountSecretRequest)(nil), // 13: auth.v1.RotateServiceAccountSecretRequest + (*RemoveServiceAccountRequest)(nil), // 14: auth.v1.RemoveServiceAccountRequest + (*GetAuthConfigResponse)(nil), // 15: auth.v1.GetAuthConfigResponse + (*LoginResponse)(nil), // 16: auth.v1.LoginResponse + (*GetTokenResponse)(nil), // 17: auth.v1.GetTokenResponse + (*LogoutResponse)(nil), // 18: auth.v1.LogoutResponse + (*RegisterResponse)(nil), // 19: auth.v1.RegisterResponse + (*AcceptInviteResponse)(nil), // 20: auth.v1.AcceptInviteResponse + (*GetSessionResponse)(nil), // 21: auth.v1.GetSessionResponse + (*ListMembersResponse)(nil), // 22: auth.v1.ListMembersResponse + (*InviteMemberResponse)(nil), // 23: auth.v1.InviteMemberResponse + (*SetMemberRoleResponse)(nil), // 24: auth.v1.SetMemberRoleResponse + (*RemoveMemberResponse)(nil), // 25: auth.v1.RemoveMemberResponse + (*ListServiceAccountsResponse)(nil), // 26: auth.v1.ListServiceAccountsResponse + (*CreateServiceAccountResponse)(nil), // 27: auth.v1.CreateServiceAccountResponse + (*RotateServiceAccountSecretResponse)(nil), // 28: auth.v1.RotateServiceAccountSecretResponse + (*RemoveServiceAccountResponse)(nil), // 29: auth.v1.RemoveServiceAccountResponse +} +var file_auth_v1_service_proto_depIdxs = []int32{ + 0, // 0: auth.v1.AuthService.GetAuthConfig:input_type -> auth.v1.GetAuthConfigRequest + 1, // 1: auth.v1.AuthService.Login:input_type -> auth.v1.LoginRequest + 2, // 2: auth.v1.AuthService.GetToken:input_type -> auth.v1.GetTokenRequest + 3, // 3: auth.v1.AuthService.Logout:input_type -> auth.v1.LogoutRequest + 4, // 4: auth.v1.AuthService.Register:input_type -> auth.v1.RegisterRequest + 5, // 5: auth.v1.AuthService.AcceptInvite:input_type -> auth.v1.AcceptInviteRequest + 6, // 6: auth.v1.AuthService.GetSession:input_type -> auth.v1.GetSessionRequest + 7, // 7: auth.v1.AuthService.ListMembers:input_type -> auth.v1.ListMembersRequest + 8, // 8: auth.v1.AuthService.InviteMember:input_type -> auth.v1.InviteMemberRequest + 9, // 9: auth.v1.AuthService.SetMemberRole:input_type -> auth.v1.SetMemberRoleRequest + 10, // 10: auth.v1.AuthService.RemoveMember:input_type -> auth.v1.RemoveMemberRequest + 11, // 11: auth.v1.AuthService.ListServiceAccounts:input_type -> auth.v1.ListServiceAccountsRequest + 12, // 12: auth.v1.AuthService.CreateServiceAccount:input_type -> auth.v1.CreateServiceAccountRequest + 13, // 13: auth.v1.AuthService.RotateServiceAccountSecret:input_type -> auth.v1.RotateServiceAccountSecretRequest + 14, // 14: auth.v1.AuthService.RemoveServiceAccount:input_type -> auth.v1.RemoveServiceAccountRequest + 15, // 15: auth.v1.AuthService.GetAuthConfig:output_type -> auth.v1.GetAuthConfigResponse + 16, // 16: auth.v1.AuthService.Login:output_type -> auth.v1.LoginResponse + 17, // 17: auth.v1.AuthService.GetToken:output_type -> auth.v1.GetTokenResponse + 18, // 18: auth.v1.AuthService.Logout:output_type -> auth.v1.LogoutResponse + 19, // 19: auth.v1.AuthService.Register:output_type -> auth.v1.RegisterResponse + 20, // 20: auth.v1.AuthService.AcceptInvite:output_type -> auth.v1.AcceptInviteResponse + 21, // 21: auth.v1.AuthService.GetSession:output_type -> auth.v1.GetSessionResponse + 22, // 22: auth.v1.AuthService.ListMembers:output_type -> auth.v1.ListMembersResponse + 23, // 23: auth.v1.AuthService.InviteMember:output_type -> auth.v1.InviteMemberResponse + 24, // 24: auth.v1.AuthService.SetMemberRole:output_type -> auth.v1.SetMemberRoleResponse + 25, // 25: auth.v1.AuthService.RemoveMember:output_type -> auth.v1.RemoveMemberResponse + 26, // 26: auth.v1.AuthService.ListServiceAccounts:output_type -> auth.v1.ListServiceAccountsResponse + 27, // 27: auth.v1.AuthService.CreateServiceAccount:output_type -> auth.v1.CreateServiceAccountResponse + 28, // 28: auth.v1.AuthService.RotateServiceAccountSecret:output_type -> auth.v1.RotateServiceAccountSecretResponse + 29, // 29: auth.v1.AuthService.RemoveServiceAccount:output_type -> auth.v1.RemoveServiceAccountResponse + 15, // [15:30] is the sub-list for method output_type + 0, // [0:15] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_auth_v1_service_proto_init() } +func file_auth_v1_service_proto_init() { + if File_auth_v1_service_proto != nil { + return + } + file_auth_v1_members_proto_init() + file_auth_v1_service_accounts_proto_init() + file_auth_v1_session_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_auth_v1_service_proto_rawDesc), len(file_auth_v1_service_proto_rawDesc)), + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_auth_v1_service_proto_goTypes, + DependencyIndexes: file_auth_v1_service_proto_depIdxs, + }.Build() + File_auth_v1_service_proto = out.File + file_auth_v1_service_proto_goTypes = nil + file_auth_v1_service_proto_depIdxs = nil +} diff --git a/identity/auth/v1/service_accounts.pb.go b/identity/auth/v1/service_accounts.pb.go new file mode 100644 index 0000000..6c85fbd --- /dev/null +++ b/identity/auth/v1/service_accounts.pb.go @@ -0,0 +1,575 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: auth/v1/service_accounts.proto + +package authv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// ServiceAccount is a non-human identity used by automation such as the +// app's CLI. Its client secret is never returned by list operations. +type ServiceAccount struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ClientId string `protobuf:"bytes,2,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` + Role Role `protobuf:"varint,5,opt,name=role,proto3,enum=auth.v1.Role" json:"role,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServiceAccount) Reset() { + *x = ServiceAccount{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServiceAccount) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServiceAccount) ProtoMessage() {} + +func (x *ServiceAccount) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServiceAccount.ProtoReflect.Descriptor instead. +func (*ServiceAccount) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{0} +} + +func (x *ServiceAccount) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *ServiceAccount) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +func (x *ServiceAccount) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ServiceAccount) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ServiceAccount) GetRole() Role { + if x != nil { + return x.Role + } + return Role_ROLE_UNSPECIFIED +} + +type ListServiceAccountsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListServiceAccountsRequest) Reset() { + *x = ListServiceAccountsRequest{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListServiceAccountsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListServiceAccountsRequest) ProtoMessage() {} + +func (x *ListServiceAccountsRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListServiceAccountsRequest.ProtoReflect.Descriptor instead. +func (*ListServiceAccountsRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{1} +} + +type ListServiceAccountsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ServiceAccounts []*ServiceAccount `protobuf:"bytes,1,rep,name=service_accounts,json=serviceAccounts,proto3" json:"service_accounts,omitempty"` + CanManage bool `protobuf:"varint,2,opt,name=can_manage,json=canManage,proto3" json:"can_manage,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListServiceAccountsResponse) Reset() { + *x = ListServiceAccountsResponse{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListServiceAccountsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListServiceAccountsResponse) ProtoMessage() {} + +func (x *ListServiceAccountsResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListServiceAccountsResponse.ProtoReflect.Descriptor instead. +func (*ListServiceAccountsResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{2} +} + +func (x *ListServiceAccountsResponse) GetServiceAccounts() []*ServiceAccount { + if x != nil { + return x.ServiceAccounts + } + return nil +} + +func (x *ListServiceAccountsResponse) GetCanManage() bool { + if x != nil { + return x.CanManage + } + return false +} + +type CreateServiceAccountRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + Role Role `protobuf:"varint,3,opt,name=role,proto3,enum=auth.v1.Role" json:"role,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateServiceAccountRequest) Reset() { + *x = CreateServiceAccountRequest{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateServiceAccountRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateServiceAccountRequest) ProtoMessage() {} + +func (x *CreateServiceAccountRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateServiceAccountRequest.ProtoReflect.Descriptor instead. +func (*CreateServiceAccountRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{3} +} + +func (x *CreateServiceAccountRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CreateServiceAccountRequest) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *CreateServiceAccountRequest) GetRole() Role { + if x != nil { + return x.Role + } + return Role_ROLE_UNSPECIFIED +} + +// CreateServiceAccountResponse returns client_secret exactly once. The app +// does not persist it and Zitadel cannot retrieve it again. +type CreateServiceAccountResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ServiceAccount *ServiceAccount `protobuf:"bytes,1,opt,name=service_account,json=serviceAccount,proto3" json:"service_account,omitempty"` + ClientSecret string `protobuf:"bytes,2,opt,name=client_secret,json=clientSecret,proto3" json:"client_secret,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateServiceAccountResponse) Reset() { + *x = CreateServiceAccountResponse{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateServiceAccountResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateServiceAccountResponse) ProtoMessage() {} + +func (x *CreateServiceAccountResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateServiceAccountResponse.ProtoReflect.Descriptor instead. +func (*CreateServiceAccountResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{4} +} + +func (x *CreateServiceAccountResponse) GetServiceAccount() *ServiceAccount { + if x != nil { + return x.ServiceAccount + } + return nil +} + +func (x *CreateServiceAccountResponse) GetClientSecret() string { + if x != nil { + return x.ClientSecret + } + return "" +} + +type RotateServiceAccountSecretRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RotateServiceAccountSecretRequest) Reset() { + *x = RotateServiceAccountSecretRequest{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RotateServiceAccountSecretRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RotateServiceAccountSecretRequest) ProtoMessage() {} + +func (x *RotateServiceAccountSecretRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RotateServiceAccountSecretRequest.ProtoReflect.Descriptor instead. +func (*RotateServiceAccountSecretRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{5} +} + +func (x *RotateServiceAccountSecretRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +// RotateServiceAccountSecretResponse returns the replacement secret exactly +// once. The previous secret is invalid after rotation. +type RotateServiceAccountSecretResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + ClientSecret string `protobuf:"bytes,2,opt,name=client_secret,json=clientSecret,proto3" json:"client_secret,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RotateServiceAccountSecretResponse) Reset() { + *x = RotateServiceAccountSecretResponse{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RotateServiceAccountSecretResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RotateServiceAccountSecretResponse) ProtoMessage() {} + +func (x *RotateServiceAccountSecretResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RotateServiceAccountSecretResponse.ProtoReflect.Descriptor instead. +func (*RotateServiceAccountSecretResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{6} +} + +func (x *RotateServiceAccountSecretResponse) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +func (x *RotateServiceAccountSecretResponse) GetClientSecret() string { + if x != nil { + return x.ClientSecret + } + return "" +} + +type RemoveServiceAccountRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveServiceAccountRequest) Reset() { + *x = RemoveServiceAccountRequest{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveServiceAccountRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveServiceAccountRequest) ProtoMessage() {} + +func (x *RemoveServiceAccountRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveServiceAccountRequest.ProtoReflect.Descriptor instead. +func (*RemoveServiceAccountRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{7} +} + +func (x *RemoveServiceAccountRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +type RemoveServiceAccountResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveServiceAccountResponse) Reset() { + *x = RemoveServiceAccountResponse{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveServiceAccountResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveServiceAccountResponse) ProtoMessage() {} + +func (x *RemoveServiceAccountResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveServiceAccountResponse.ProtoReflect.Descriptor instead. +func (*RemoveServiceAccountResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{8} +} + +var File_auth_v1_service_accounts_proto protoreflect.FileDescriptor + +const file_auth_v1_service_accounts_proto_rawDesc = "" + + "\n" + + "\x1eauth/v1/service_accounts.proto\x12\aauth.v1\x1a\x15auth/v1/members.proto\"\x9f\x01\n" + + "\x0eServiceAccount\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x1b\n" + + "\tclient_id\x18\x02 \x01(\tR\bclientId\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x04 \x01(\tR\vdescription\x12!\n" + + "\x04role\x18\x05 \x01(\x0e2\r.auth.v1.RoleR\x04role\"\x1c\n" + + "\x1aListServiceAccountsRequest\"\x80\x01\n" + + "\x1bListServiceAccountsResponse\x12B\n" + + "\x10service_accounts\x18\x01 \x03(\v2\x17.auth.v1.ServiceAccountR\x0fserviceAccounts\x12\x1d\n" + + "\n" + + "can_manage\x18\x02 \x01(\bR\tcanManage\"v\n" + + "\x1bCreateServiceAccountRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x02 \x01(\tR\vdescription\x12!\n" + + "\x04role\x18\x03 \x01(\x0e2\r.auth.v1.RoleR\x04role\"\x85\x01\n" + + "\x1cCreateServiceAccountResponse\x12@\n" + + "\x0fservice_account\x18\x01 \x01(\v2\x17.auth.v1.ServiceAccountR\x0eserviceAccount\x12#\n" + + "\rclient_secret\x18\x02 \x01(\tR\fclientSecret\"<\n" + + "!RotateServiceAccountSecretRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\"f\n" + + "\"RotateServiceAccountSecretResponse\x12\x1b\n" + + "\tclient_id\x18\x01 \x01(\tR\bclientId\x12#\n" + + "\rclient_secret\x18\x02 \x01(\tR\fclientSecret\"6\n" + + "\x1bRemoveServiceAccountRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\"\x1e\n" + + "\x1cRemoveServiceAccountResponseB\x96\x01\n" + + "\vcom.auth.v1B\x14ServiceAccountsProtoP\x01Z4github.com/galaxy-io/modules/identity/auth/v1;authv1\xa2\x02\x03AXX\xaa\x02\aAuth.V1\xca\x02\aAuth\\V1\xe2\x02\x13Auth\\V1\\GPBMetadata\xea\x02\bAuth::V1b\x06proto3" + +var ( + file_auth_v1_service_accounts_proto_rawDescOnce sync.Once + file_auth_v1_service_accounts_proto_rawDescData []byte +) + +func file_auth_v1_service_accounts_proto_rawDescGZIP() []byte { + file_auth_v1_service_accounts_proto_rawDescOnce.Do(func() { + file_auth_v1_service_accounts_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_auth_v1_service_accounts_proto_rawDesc), len(file_auth_v1_service_accounts_proto_rawDesc))) + }) + return file_auth_v1_service_accounts_proto_rawDescData +} + +var file_auth_v1_service_accounts_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_auth_v1_service_accounts_proto_goTypes = []any{ + (*ServiceAccount)(nil), // 0: auth.v1.ServiceAccount + (*ListServiceAccountsRequest)(nil), // 1: auth.v1.ListServiceAccountsRequest + (*ListServiceAccountsResponse)(nil), // 2: auth.v1.ListServiceAccountsResponse + (*CreateServiceAccountRequest)(nil), // 3: auth.v1.CreateServiceAccountRequest + (*CreateServiceAccountResponse)(nil), // 4: auth.v1.CreateServiceAccountResponse + (*RotateServiceAccountSecretRequest)(nil), // 5: auth.v1.RotateServiceAccountSecretRequest + (*RotateServiceAccountSecretResponse)(nil), // 6: auth.v1.RotateServiceAccountSecretResponse + (*RemoveServiceAccountRequest)(nil), // 7: auth.v1.RemoveServiceAccountRequest + (*RemoveServiceAccountResponse)(nil), // 8: auth.v1.RemoveServiceAccountResponse + (Role)(0), // 9: auth.v1.Role +} +var file_auth_v1_service_accounts_proto_depIdxs = []int32{ + 9, // 0: auth.v1.ServiceAccount.role:type_name -> auth.v1.Role + 0, // 1: auth.v1.ListServiceAccountsResponse.service_accounts:type_name -> auth.v1.ServiceAccount + 9, // 2: auth.v1.CreateServiceAccountRequest.role:type_name -> auth.v1.Role + 0, // 3: auth.v1.CreateServiceAccountResponse.service_account:type_name -> auth.v1.ServiceAccount + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_auth_v1_service_accounts_proto_init() } +func file_auth_v1_service_accounts_proto_init() { + if File_auth_v1_service_accounts_proto != nil { + return + } + file_auth_v1_members_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_auth_v1_service_accounts_proto_rawDesc), len(file_auth_v1_service_accounts_proto_rawDesc)), + NumEnums: 0, + NumMessages: 9, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_auth_v1_service_accounts_proto_goTypes, + DependencyIndexes: file_auth_v1_service_accounts_proto_depIdxs, + MessageInfos: file_auth_v1_service_accounts_proto_msgTypes, + }.Build() + File_auth_v1_service_accounts_proto = out.File + file_auth_v1_service_accounts_proto_goTypes = nil + file_auth_v1_service_accounts_proto_depIdxs = nil +} diff --git a/identity/auth/v1/session.pb.go b/identity/auth/v1/session.pb.go new file mode 100644 index 0000000..3463c8a --- /dev/null +++ b/identity/auth/v1/session.pb.go @@ -0,0 +1,834 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: auth/v1/session.proto + +package authv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetAuthConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAuthConfigRequest) Reset() { + *x = GetAuthConfigRequest{} + mi := &file_auth_v1_session_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAuthConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAuthConfigRequest) ProtoMessage() {} + +func (x *GetAuthConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAuthConfigRequest.ProtoReflect.Descriptor instead. +func (*GetAuthConfigRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{0} +} + +// GetAuthConfigResponse tells clients how to authenticate. An empty issuer +// means no provider is configured: the UI renders without a session and every +// other AuthService RPC is unimplemented. +type GetAuthConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Issuer string `protobuf:"bytes,1,opt,name=issuer,proto3" json:"issuer,omitempty"` + // service_account_scopes are requested by non-interactive clients. The + // provider owns these values so clients do not need provider-specific + // project IDs or reserved scope knowledge. + ServiceAccountScopes []string `protobuf:"bytes,2,rep,name=service_account_scopes,json=serviceAccountScopes,proto3" json:"service_account_scopes,omitempty"` + // invite_only means sign-up is closed: Register is refused and the UI + // offers no way to create an organization. People join by invitation. + InviteOnly bool `protobuf:"varint,3,opt,name=invite_only,json=inviteOnly,proto3" json:"invite_only,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAuthConfigResponse) Reset() { + *x = GetAuthConfigResponse{} + mi := &file_auth_v1_session_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAuthConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAuthConfigResponse) ProtoMessage() {} + +func (x *GetAuthConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAuthConfigResponse.ProtoReflect.Descriptor instead. +func (*GetAuthConfigResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{1} +} + +func (x *GetAuthConfigResponse) GetIssuer() string { + if x != nil { + return x.Issuer + } + return "" +} + +func (x *GetAuthConfigResponse) GetServiceAccountScopes() []string { + if x != nil { + return x.ServiceAccountScopes + } + return nil +} + +func (x *GetAuthConfigResponse) GetInviteOnly() bool { + if x != nil { + return x.InviteOnly + } + return false +} + +// LoginRequest carries the credentials the app's own login page collected. +// A successful login answers with an HttpOnly session cookie that every later +// RPC carries; nothing about the provider reaches the browser. +type LoginRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + LoginName string `protobuf:"bytes,1,opt,name=login_name,json=loginName,proto3" json:"login_name,omitempty"` + Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LoginRequest) Reset() { + *x = LoginRequest{} + mi := &file_auth_v1_session_proto_msgTypes[2] + 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_v1_session_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LoginRequest.ProtoReflect.Descriptor instead. +func (*LoginRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{2} +} + +func (x *LoginRequest) GetLoginName() string { + if x != nil { + return x.LoginName + } + return "" +} + +func (x *LoginRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +type LoginResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LoginResponse) Reset() { + *x = LoginResponse{} + mi := &file_auth_v1_session_proto_msgTypes[3] + 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_v1_session_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LoginResponse.ProtoReflect.Descriptor instead. +func (*LoginResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{3} +} + +// GetTokenRequest exchanges a service account's credentials for an access +// token. The server runs the grant against the provider, so SDKs and the +// CLI need no provider knowledge and no route to it. +type GetTokenRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + ClientSecret string `protobuf:"bytes,2,opt,name=client_secret,json=clientSecret,proto3" json:"client_secret,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTokenRequest) Reset() { + *x = GetTokenRequest{} + mi := &file_auth_v1_session_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTokenRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTokenRequest) ProtoMessage() {} + +func (x *GetTokenRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTokenRequest.ProtoReflect.Descriptor instead. +func (*GetTokenRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{4} +} + +func (x *GetTokenRequest) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +func (x *GetTokenRequest) GetClientSecret() string { + if x != nil { + return x.ClientSecret + } + return "" +} + +// GetTokenResponse carries a bearer token and its lifetime in seconds. +// Clients mint again as it nears expiry. +type GetTokenResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + AccessToken string `protobuf:"bytes,1,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` + ExpiresIn int32 `protobuf:"varint,2,opt,name=expires_in,json=expiresIn,proto3" json:"expires_in,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTokenResponse) Reset() { + *x = GetTokenResponse{} + mi := &file_auth_v1_session_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTokenResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTokenResponse) ProtoMessage() {} + +func (x *GetTokenResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTokenResponse.ProtoReflect.Descriptor instead. +func (*GetTokenResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{5} +} + +func (x *GetTokenResponse) GetAccessToken() string { + if x != nil { + return x.AccessToken + } + return "" +} + +func (x *GetTokenResponse) GetExpiresIn() int32 { + if x != nil { + return x.ExpiresIn + } + return 0 +} + +// LogoutRequest ends the session and clears its cookie. +type LogoutRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LogoutRequest) Reset() { + *x = LogoutRequest{} + mi := &file_auth_v1_session_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LogoutRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LogoutRequest) ProtoMessage() {} + +func (x *LogoutRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead. +func (*LogoutRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{6} +} + +type LogoutResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LogoutResponse) Reset() { + *x = LogoutResponse{} + mi := &file_auth_v1_session_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LogoutResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LogoutResponse) ProtoMessage() {} + +func (x *LogoutResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead. +func (*LogoutResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{7} +} + +// GetSessionRequest resolves the caller behind the session cookie. It is the +// UI's sign-in check: unauthenticated means show the login page. +type GetSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSessionRequest) Reset() { + *x = GetSessionRequest{} + mi := &file_auth_v1_session_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSessionRequest) ProtoMessage() {} + +func (x *GetSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSessionRequest.ProtoReflect.Descriptor instead. +func (*GetSessionRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{8} +} + +type GetSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` + AvatarUrl string `protobuf:"bytes,4,opt,name=avatar_url,json=avatarUrl,proto3" json:"avatar_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSessionResponse) Reset() { + *x = GetSessionResponse{} + mi := &file_auth_v1_session_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSessionResponse) ProtoMessage() {} + +func (x *GetSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSessionResponse.ProtoReflect.Descriptor instead. +func (*GetSessionResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{9} +} + +func (x *GetSessionResponse) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *GetSessionResponse) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *GetSessionResponse) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *GetSessionResponse) GetAvatarUrl() string { + if x != nil { + return x.AvatarUrl + } + return "" +} + +// RegisterRequest creates a tenant and its first admin. +type RegisterRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + OrgName string `protobuf:"bytes,1,opt,name=org_name,json=orgName,proto3" json:"org_name,omitempty"` + GivenName string `protobuf:"bytes,2,opt,name=given_name,json=givenName,proto3" json:"given_name,omitempty"` + FamilyName string `protobuf:"bytes,3,opt,name=family_name,json=familyName,proto3" json:"family_name,omitempty"` + Email string `protobuf:"bytes,4,opt,name=email,proto3" json:"email,omitempty"` + Password string `protobuf:"bytes,5,opt,name=password,proto3" json:"password,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterRequest) Reset() { + *x = RegisterRequest{} + mi := &file_auth_v1_session_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterRequest) ProtoMessage() {} + +func (x *RegisterRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterRequest.ProtoReflect.Descriptor instead. +func (*RegisterRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{10} +} + +func (x *RegisterRequest) GetOrgName() string { + if x != nil { + return x.OrgName + } + return "" +} + +func (x *RegisterRequest) GetGivenName() string { + if x != nil { + return x.GivenName + } + return "" +} + +func (x *RegisterRequest) GetFamilyName() string { + if x != nil { + return x.FamilyName + } + return "" +} + +func (x *RegisterRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *RegisterRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +type RegisterResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + TenantId string `protobuf:"bytes,1,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterResponse) Reset() { + *x = RegisterResponse{} + mi := &file_auth_v1_session_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterResponse) ProtoMessage() {} + +func (x *RegisterResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterResponse.ProtoReflect.Descriptor instead. +func (*RegisterResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{11} +} + +func (x *RegisterResponse) GetTenantId() string { + if x != nil { + return x.TenantId + } + return "" +} + +// AcceptInviteRequest redeems an invite and sets the new member's password. +// The code is the credential, which is why this RPC is public. +type AcceptInviteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Code string `protobuf:"bytes,2,opt,name=code,proto3" json:"code,omitempty"` + Password string `protobuf:"bytes,3,opt,name=password,proto3" json:"password,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AcceptInviteRequest) Reset() { + *x = AcceptInviteRequest{} + mi := &file_auth_v1_session_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AcceptInviteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AcceptInviteRequest) ProtoMessage() {} + +func (x *AcceptInviteRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AcceptInviteRequest.ProtoReflect.Descriptor instead. +func (*AcceptInviteRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{12} +} + +func (x *AcceptInviteRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *AcceptInviteRequest) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *AcceptInviteRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +type AcceptInviteResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AcceptInviteResponse) Reset() { + *x = AcceptInviteResponse{} + mi := &file_auth_v1_session_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AcceptInviteResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AcceptInviteResponse) ProtoMessage() {} + +func (x *AcceptInviteResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AcceptInviteResponse.ProtoReflect.Descriptor instead. +func (*AcceptInviteResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{13} +} + +var File_auth_v1_session_proto protoreflect.FileDescriptor + +const file_auth_v1_session_proto_rawDesc = "" + + "\n" + + "\x15auth/v1/session.proto\x12\aauth.v1\"\x16\n" + + "\x14GetAuthConfigRequest\"\x86\x01\n" + + "\x15GetAuthConfigResponse\x12\x16\n" + + "\x06issuer\x18\x01 \x01(\tR\x06issuer\x124\n" + + "\x16service_account_scopes\x18\x02 \x03(\tR\x14serviceAccountScopes\x12\x1f\n" + + "\vinvite_only\x18\x03 \x01(\bR\n" + + "inviteOnly\"I\n" + + "\fLoginRequest\x12\x1d\n" + + "\n" + + "login_name\x18\x01 \x01(\tR\tloginName\x12\x1a\n" + + "\bpassword\x18\x02 \x01(\tR\bpassword\"\x0f\n" + + "\rLoginResponse\"S\n" + + "\x0fGetTokenRequest\x12\x1b\n" + + "\tclient_id\x18\x01 \x01(\tR\bclientId\x12#\n" + + "\rclient_secret\x18\x02 \x01(\tR\fclientSecret\"T\n" + + "\x10GetTokenResponse\x12!\n" + + "\faccess_token\x18\x01 \x01(\tR\vaccessToken\x12\x1d\n" + + "\n" + + "expires_in\x18\x02 \x01(\x05R\texpiresIn\"\x0f\n" + + "\rLogoutRequest\"\x10\n" + + "\x0eLogoutResponse\"\x13\n" + + "\x11GetSessionRequest\"v\n" + + "\x12GetSessionResponse\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" + + "\x05email\x18\x03 \x01(\tR\x05email\x12\x1d\n" + + "\n" + + "avatar_url\x18\x04 \x01(\tR\tavatarUrl\"\x9e\x01\n" + + "\x0fRegisterRequest\x12\x19\n" + + "\borg_name\x18\x01 \x01(\tR\aorgName\x12\x1d\n" + + "\n" + + "given_name\x18\x02 \x01(\tR\tgivenName\x12\x1f\n" + + "\vfamily_name\x18\x03 \x01(\tR\n" + + "familyName\x12\x14\n" + + "\x05email\x18\x04 \x01(\tR\x05email\x12\x1a\n" + + "\bpassword\x18\x05 \x01(\tR\bpassword\"/\n" + + "\x10RegisterResponse\x12\x1b\n" + + "\ttenant_id\x18\x01 \x01(\tR\btenantId\"^\n" + + "\x13AcceptInviteRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x12\n" + + "\x04code\x18\x02 \x01(\tR\x04code\x12\x1a\n" + + "\bpassword\x18\x03 \x01(\tR\bpassword\"\x16\n" + + "\x14AcceptInviteResponseB\x8e\x01\n" + + "\vcom.auth.v1B\fSessionProtoP\x01Z4github.com/galaxy-io/modules/identity/auth/v1;authv1\xa2\x02\x03AXX\xaa\x02\aAuth.V1\xca\x02\aAuth\\V1\xe2\x02\x13Auth\\V1\\GPBMetadata\xea\x02\bAuth::V1b\x06proto3" + +var ( + file_auth_v1_session_proto_rawDescOnce sync.Once + file_auth_v1_session_proto_rawDescData []byte +) + +func file_auth_v1_session_proto_rawDescGZIP() []byte { + file_auth_v1_session_proto_rawDescOnce.Do(func() { + file_auth_v1_session_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_auth_v1_session_proto_rawDesc), len(file_auth_v1_session_proto_rawDesc))) + }) + return file_auth_v1_session_proto_rawDescData +} + +var file_auth_v1_session_proto_msgTypes = make([]protoimpl.MessageInfo, 14) +var file_auth_v1_session_proto_goTypes = []any{ + (*GetAuthConfigRequest)(nil), // 0: auth.v1.GetAuthConfigRequest + (*GetAuthConfigResponse)(nil), // 1: auth.v1.GetAuthConfigResponse + (*LoginRequest)(nil), // 2: auth.v1.LoginRequest + (*LoginResponse)(nil), // 3: auth.v1.LoginResponse + (*GetTokenRequest)(nil), // 4: auth.v1.GetTokenRequest + (*GetTokenResponse)(nil), // 5: auth.v1.GetTokenResponse + (*LogoutRequest)(nil), // 6: auth.v1.LogoutRequest + (*LogoutResponse)(nil), // 7: auth.v1.LogoutResponse + (*GetSessionRequest)(nil), // 8: auth.v1.GetSessionRequest + (*GetSessionResponse)(nil), // 9: auth.v1.GetSessionResponse + (*RegisterRequest)(nil), // 10: auth.v1.RegisterRequest + (*RegisterResponse)(nil), // 11: auth.v1.RegisterResponse + (*AcceptInviteRequest)(nil), // 12: auth.v1.AcceptInviteRequest + (*AcceptInviteResponse)(nil), // 13: auth.v1.AcceptInviteResponse +} +var file_auth_v1_session_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_auth_v1_session_proto_init() } +func file_auth_v1_session_proto_init() { + if File_auth_v1_session_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_auth_v1_session_proto_rawDesc), len(file_auth_v1_session_proto_rawDesc)), + NumEnums: 0, + NumMessages: 14, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_auth_v1_session_proto_goTypes, + DependencyIndexes: file_auth_v1_session_proto_depIdxs, + MessageInfos: file_auth_v1_session_proto_msgTypes, + }.Build() + File_auth_v1_session_proto = out.File + file_auth_v1_session_proto_goTypes = nil + file_auth_v1_session_proto_depIdxs = nil +} diff --git a/identity/buf.gen.yaml b/identity/buf.gen.yaml new file mode 100644 index 0000000..e5789f4 --- /dev/null +++ b/identity/buf.gen.yaml @@ -0,0 +1,13 @@ +version: v2 +managed: + enabled: true + override: + - file_option: go_package_prefix + value: github.com/galaxy-io/modules/identity +plugins: + - local: protoc-gen-go + out: . + opt: paths=source_relative + - local: protoc-gen-connect-go + out: . + opt: paths=source_relative diff --git a/identity/buf.yaml b/identity/buf.yaml new file mode 100644 index 0000000..ba5ddf5 --- /dev/null +++ b/identity/buf.yaml @@ -0,0 +1,3 @@ +version: v2 +modules: + - path: proto diff --git a/identity/go.mod b/identity/go.mod new file mode 100644 index 0000000..4f6cd6f --- /dev/null +++ b/identity/go.mod @@ -0,0 +1,41 @@ +module github.com/galaxy-io/modules/identity + +go 1.26.4 + +require ( + connectrpc.com/connect v1.20.0 + github.com/coreos/go-oidc/v3 v3.17.0 + github.com/zitadel/oidc/v3 v3.47.8 + github.com/zitadel/zitadel-go/v3 v3.29.2 + golang.org/x/oauth2 v0.36.0 + google.golang.org/grpc v1.83.2 + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af +) + +require ( + github.com/bmatcuk/doublestar/v4 v4.10.4 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/go-chi/chi/v5 v5.5.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.6 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect + github.com/muhlemmer/httpforwarded v0.1.0 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/zitadel/logging v0.7.0 // indirect + github.com/zitadel/schema v1.3.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.46.0 // indirect + go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/otel/trace v1.46.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.43.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260817212433-ac3dfec99bb1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260817212433-ac3dfec99bb1 // indirect +) diff --git a/identity/go.sum b/identity/go.sum new file mode 100644 index 0000000..c97605b --- /dev/null +++ b/identity/go.sum @@ -0,0 +1,99 @@ +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= +github.com/bmatcuk/doublestar/v4 v4.10.4 h1:oTKkRmRuOsKMqFe1AQazd9gGBFNEpOmgKp/xzB97dnA= +github.com/bmatcuk/doublestar/v4 v4.10.4/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/go-chi/chi/v5 v5.5.0 h1:xdKQ1dJmVJsmgahrXSKhUx8NyVPzpwdlyrrQFpMPJgg= +github.com/go-chi/chi/v5 v5.5.0/go.mod h1:d1IG7GtKPSjPyBP365PH8sAGVHk3qfB/IhjFNRH1wWM= +github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4= +github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= +github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA= +github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/zitadel/logging v0.7.0 h1:eugftwMM95Wgqwftsvj81isL0JK/hoScVqp/7iA2adQ= +github.com/zitadel/logging v0.7.0/go.mod h1:9A6h9feBF/3u0IhA4uffdzSDY7mBaf7RE78H5sFMINQ= +github.com/zitadel/oidc/v3 v3.47.8 h1:1IcKnNsBzrUpyPyIJeNwBwa2ncrjOHQB4rdV8zuTWLk= +github.com/zitadel/oidc/v3 v3.47.8/go.mod h1:NTejBboCz6SShga9FnZiFaFiMHBOQTbGqBIP16c05Ug= +github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI= +github.com/zitadel/schema v1.3.2/go.mod h1:IZmdfF9Wu62Zu6tJJTH3UsArevs3Y4smfJIj3L8fzxw= +github.com/zitadel/zitadel-go/v3 v3.29.2 h1:4yxJG2+qwm25ykrfYKmYBPQJXXZEKKBme84fmVElXRw= +github.com/zitadel/zitadel-go/v3 v3.29.2/go.mod h1:lPi7b/gLnU/vTk1qjL8/7GT5plux5VmWoqw6U6vjPbA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260817212433-ac3dfec99bb1 h1:lrupDmKL3p5kEX1M92oan027eCKcouzjuPbH6YBK+Rs= +google.golang.org/genproto/googleapis/api v0.0.0-20260817212433-ac3dfec99bb1/go.mod h1:q/3oV3jAi5vwelxsVAprMBC8BcM2zmNe+IjRGd+9/ks= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260817212433-ac3dfec99bb1 h1:utmqiUzZAEINbLmRyAt1QjVkMyLQ+lvIwCpLaB1rZfw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260817212433-ac3dfec99bb1/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/identity/identity.go b/identity/identity.go new file mode 100644 index 0000000..58c999f --- /dev/null +++ b/identity/identity.go @@ -0,0 +1,136 @@ +// Package identity is an app's authentication port. The keycloak and +// zitadel packages are full providers an app serves; the proxy package +// trusts a gateway in front of the app instead. It is meant to be optional: +// with no provider configured an app runs unauthenticated and falls back to +// its default tenant. +package identity + +import ( + "context" + "net/http" + "slices" + + authv1 "github.com/galaxy-io/modules/identity/auth/v1" + "github.com/galaxy-io/modules/identity/auth/v1/authv1connect" +) + +// Authenticator resolves the credentials on a request to its caller. It is +// the one method the RPC interceptor needs that no service definition +// covers. +type Authenticator interface { + // Authenticate resolves the credentials on a request's headers, a bearer + // token, a session cookie or identity headers set by a trusted gateway, + // to their caller. It fails when there are none, they are invalid, or + // they carry no tenant; a *connect.Error keeps its code on the way to + // the client. + Authenticate(ctx context.Context, header http.Header) (Caller, error) +} + +// Provider is an Authenticator that also serves AuthService: login, session, +// members and service accounts. Implementing the generated handler is the +// bulk of it. An Authenticator that is not a Provider, such as a gateway +// proxy, leaves sign-in to something upstream. +type Provider interface { + Authenticator + authv1connect.AuthServiceHandler +} + +// Caller is the authenticated identity behind a request. +type Caller struct { + // UserID is the provider's stable user identifier. + UserID string + // ID is the app's own id for the user, minted from UserID within Tenant + // on first sight. Providers leave it empty; the app fills it in. + ID string + // Tenant is the app's own id for the caller's tenant, resolved from + // TenantExternalID on first sight. Providers leave it empty; the app + // fills it in. + Tenant string + // TenantExternalID is the provider's id for the tenant; a provider maps + // its own organization or workspace concept onto it. + TenantExternalID string + // TenantName is a display name for the tenant when the provider has one. + TenantName string + Roles []authv1.Role + // Service names a trusted service calling on its own behalf rather than + // a person. A service caller has a tenant but no user row. + Service string +} + +// HasRole reports whether the caller holds role. +func (c Caller) HasRole(role authv1.Role) bool { return slices.Contains(c.Roles, role) } + +// IsAdmin reports whether the caller administers their tenant. +func (c Caller) IsAdmin() bool { return c.HasRole(authv1.Role_ROLE_ADMIN) } + +// roleKeys is the wire vocabulary providers store grants under. Keeping the +// mapping here means an adapter never invents its own role names. +var roleKeys = map[authv1.Role]string{ + authv1.Role_ROLE_ADMIN: "admin", + authv1.Role_ROLE_CREATOR: "creator", + authv1.Role_ROLE_VIEWER: "viewer", +} + +// RoleKey returns the provider-facing key for role, empty when unspecified. +func RoleKey(role authv1.Role) string { return roleKeys[role] } + +// RoleKeys returns every role key the port recognizes. +func RoleKeys() []string { + keys := make([]string, 0, len(roleKeys)) + for _, role := range []authv1.Role{ + authv1.Role_ROLE_ADMIN, + authv1.Role_ROLE_CREATOR, + authv1.Role_ROLE_VIEWER, + } { + keys = append(keys, roleKeys[role]) + } + return keys +} + +// RoleFromKey maps a provider's stored grant back onto the port's +// vocabulary; unknown keys resolve to ROLE_UNSPECIFIED. +func RoleFromKey(key string) authv1.Role { + for role, roleKey := range roleKeys { + if roleKey == key { + return role + } + } + return authv1.Role_ROLE_UNSPECIFIED +} + +type ( + callerCtxKey struct{} + tenantCtxKey struct{} +) + +// WithCaller returns a context carrying the authenticated caller. +func WithCaller(ctx context.Context, caller Caller) context.Context { + return context.WithValue(ctx, callerCtxKey{}, caller) +} + +// WithTenant establishes a request's tenant without fabricating an +// authenticated caller. Auth-disabled single-tenant servers use it to place +// their configured tenant on every request context. +func WithTenant(ctx context.Context, tenant string) context.Context { + return context.WithValue(ctx, tenantCtxKey{}, tenant) +} + +// CallerFrom returns the authenticated caller, if one was established. It +// reports false whenever auth is disabled, which is how handlers know to +// fall back to the default tenant. +func CallerFrom(ctx context.Context) (Caller, bool) { + caller, ok := ctx.Value(callerCtxKey{}).(Caller) + return caller, ok +} + +// TenantFrom returns the authenticated caller's tenant, if any. +func TenantFrom(ctx context.Context) (string, bool) { + if tenant, ok := ctx.Value(tenantCtxKey{}).(string); ok && tenant != "" { + return tenant, true + } + caller, ok := CallerFrom(ctx) + if !ok || caller.Tenant == "" { + return "", false + } + return caller.Tenant, true +} diff --git a/identity/keycloak/admin.go b/identity/keycloak/admin.go new file mode 100644 index 0000000..2fc6c00 --- /dev/null +++ b/identity/keycloak/admin.go @@ -0,0 +1,413 @@ +package keycloak + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// admin is the slice of Keycloak's admin REST API the app uses, issued as +// the app client's service account. Keycloak ships no Go SDK, so the +// representations here are the fields the app reads and writes. +type admin struct { + base string + client *http.Client +} + +// apiError is a non-2xx answer from Keycloak with the reason it gave. +type apiError struct { + status int + message string +} + +func (e *apiError) Error() string { + if e.message == "" { + return fmt.Sprintf("keycloak: status %d", e.status) + } + return fmt.Sprintf("keycloak: %s", e.message) +} + +// errUnavailable marks an error that means Keycloak could not be reached at +// all, which must never be mistaken for a rejected credential. +var errUnavailable = errors.New("keycloak unavailable") + +func isStatus(err error, status int) bool { + var e *apiError + return errors.As(err, &e) && e.status == status +} + +// do issues one request. in is JSON-encoded unless it is a string, which is +// sent verbatim; out receives a JSON body when non-nil. The Location header +// comes back so a create can read the new id off it. +func (a *admin) do(ctx context.Context, method, path string, in, out any) (string, error) { + var body io.Reader + switch v := in.(type) { + case nil: + case string: + body = strings.NewReader(v) + default: + raw, err := json.Marshal(v) + if err != nil { + return "", err + } + body = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, a.base+path, body) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + res, err := a.client.Do(req) + if err != nil { + return "", fmt.Errorf("%w: %w", errUnavailable, err) + } + defer func() { _ = res.Body.Close() }() + raw, err := io.ReadAll(res.Body) + if err != nil { + return "", fmt.Errorf("%w: %w", errUnavailable, err) + } + if res.StatusCode < 200 || res.StatusCode > 299 { + return "", &apiError{status: res.StatusCode, message: reason(raw)} + } + if out != nil && len(raw) > 0 { + if err := json.Unmarshal(raw, out); err != nil { + return "", fmt.Errorf("keycloak: decode %s %s: %w", method, path, err) + } + } + return res.Header.Get("Location"), nil +} + +func decodeJSON(r io.Reader, out any) error { return json.NewDecoder(r).Decode(out) } + +// reason pulls the message out of Keycloak's two error shapes. +func reason(raw []byte) string { + var body struct { + ErrorMessage string `json:"errorMessage"` + ErrorDescription string `json:"error_description"` + Error string `json:"error"` + } + if json.Unmarshal(raw, &body) != nil { + return strings.TrimSpace(string(raw)) + } + switch { + case body.ErrorMessage != "": + return body.ErrorMessage + case body.ErrorDescription != "": + return body.ErrorDescription + } + return body.Error +} + +// created returns the id a create answered with on its Location header. +func created(location string) string { + return location[strings.LastIndex(location, "/")+1:] +} + +type userRep struct { + ID string `json:"id,omitempty"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + Enabled bool `json:"enabled"` + EmailVerified bool `json:"emailVerified"` + Attributes map[string][]string `json:"attributes,omitempty"` + Credentials []credentialRep `json:"credentials,omitempty"` +} + +type credentialRep struct { + Type string `json:"type"` + Value string `json:"value"` + Temporary bool `json:"temporary"` +} + +type organizationRep struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Alias string `json:"alias,omitempty"` +} + +type roleRep struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` +} + +type clientRep struct { + ID string `json:"id,omitempty"` + ClientID string `json:"clientId"` + Secret string `json:"secret,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + PublicClient bool `json:"publicClient"` + ServiceAccountsEnabled bool `json:"serviceAccountsEnabled"` + StandardFlowEnabled bool `json:"standardFlowEnabled"` + DirectAccessGrantsEnabled bool `json:"directAccessGrantsEnabled"` + Attributes map[string]string `json:"attributes,omitempty"` + ProtocolMappers []protocolMapperRep `json:"protocolMappers,omitempty"` +} + +type protocolMapperRep struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Protocol string `json:"protocol"` + ProtocolMapper string `json:"protocolMapper"` + Config map[string]string `json:"config"` +} + +// audienceMapper puts the app's client on a token's audience, which the +// verifier requires. +func audienceMapper(app, clientID string) protocolMapperRep { + return protocolMapperRep{ + Name: app + " audience", + Protocol: "openid-connect", + ProtocolMapper: "oidc-audience-mapper", + Config: map[string]string{ + "included.client.audience": clientID, + "access.token.claim": "true", + "id.token.claim": "false", + }, + } +} + +func (a *admin) getUser(ctx context.Context, id string) (userRep, error) { + var user userRep + _, err := a.do(ctx, http.MethodGet, "/users/"+url.PathEscape(id), nil, &user) + return user, err +} + +// getUserRaw reads a user as Keycloak represents it, for a read-modify-write: +// an update clears every field it omits. +func (a *admin) getUserRaw(ctx context.Context, id string) (map[string]any, error) { + var user map[string]any + _, err := a.do(ctx, http.MethodGet, "/users/"+url.PathEscape(id), nil, &user) + return user, err +} + +func (a *admin) putUser(ctx context.Context, id string, user map[string]any) error { + _, err := a.do(ctx, http.MethodPut, "/users/"+url.PathEscape(id), user, nil) + return err +} + +func (a *admin) createUser(ctx context.Context, user userRep) (string, error) { + location, err := a.do(ctx, http.MethodPost, "/users", user, nil) + if err != nil { + return "", err + } + return created(location), nil +} + +// userByEmail resolves a user by their exact address. +func (a *admin) userByEmail(ctx context.Context, email string) (userRep, error) { + var found []userRep + if _, err := a.do(ctx, http.MethodGet, "/users?exact=true&email="+url.QueryEscape(email), nil, &found); err != nil { + return userRep{}, err + } + if len(found) == 0 { + return userRep{}, &apiError{status: http.StatusNotFound, message: fmt.Sprintf("user %q not found", email)} + } + return found[0], nil +} + +func (a *admin) deleteUser(ctx context.Context, id string) error { + _, err := a.do(ctx, http.MethodDelete, "/users/"+url.PathEscape(id), nil, nil) + return err +} + +func (a *admin) resetPassword(ctx context.Context, id, password string) error { + _, err := a.do(ctx, http.MethodPut, "/users/"+url.PathEscape(id)+"/reset-password", + credentialRep{Type: "password", Value: password}, nil) + return err +} + +func (a *admin) createOrganization(ctx context.Context, org organizationRep) (string, error) { + location, err := a.do(ctx, http.MethodPost, "/organizations", org, nil) + if err != nil { + return "", err + } + return created(location), nil +} + +func (a *admin) deleteOrganization(ctx context.Context, id string) error { + _, err := a.do(ctx, http.MethodDelete, "/organizations/"+url.PathEscape(id), nil, nil) + return err +} + +// organizationByAlias resolves the organization behind a token's alias. +func (a *admin) organizationByAlias(ctx context.Context, alias string) (organizationRep, error) { + var found []organizationRep + if _, err := a.do(ctx, http.MethodGet, "/organizations?q="+url.QueryEscape("alias:"+alias), nil, &found); err != nil { + return organizationRep{}, err + } + for _, org := range found { + if org.Alias == alias { + return org, nil + } + } + return organizationRep{}, &apiError{status: http.StatusNotFound, message: fmt.Sprintf("organization %q not found", alias)} +} + +func (a *admin) addMember(ctx context.Context, orgID, userID string) error { + _, err := a.do(ctx, http.MethodPost, "/organizations/"+url.PathEscape(orgID)+"/members", userID, nil) + return err +} + +func (a *admin) members(ctx context.Context, orgID string) ([]userRep, error) { + var found []userRep + _, err := a.do(ctx, http.MethodGet, "/organizations/"+url.PathEscape(orgID)+"/members?max=1000", nil, &found) + return found, err +} + +func (a *admin) memberOrganizations(ctx context.Context, userID string) ([]organizationRep, error) { + var found []organizationRep + _, err := a.do(ctx, http.MethodGet, "/organizations/members/"+url.PathEscape(userID)+"/organizations", nil, &found) + return found, err +} + +// clientByClientID resolves a client's uuid from the id tokens name it by. +func (a *admin) clientByClientID(ctx context.Context, clientID string) (clientRep, error) { + var found []clientRep + if _, err := a.do(ctx, http.MethodGet, "/clients?clientId="+url.QueryEscape(clientID), nil, &found); err != nil { + return clientRep{}, err + } + for _, client := range found { + if client.ClientID == clientID { + return client, nil + } + } + return clientRep{}, &apiError{status: http.StatusNotFound, message: fmt.Sprintf("client %q not found", clientID)} +} + +// clients lists every client in the realm. Keycloak cannot search on +// attributes, so callers filter the list themselves. +func (a *admin) clients(ctx context.Context) ([]clientRep, error) { + var found []clientRep + _, err := a.do(ctx, http.MethodGet, "/clients?max=1000", nil, &found) + return found, err +} + +// getClientRaw reads a client as Keycloak represents it, for a +// read-modify-write. +func (a *admin) getClientRaw(ctx context.Context, id string) (map[string]any, error) { + var client map[string]any + _, err := a.do(ctx, http.MethodGet, "/clients/"+url.PathEscape(id), nil, &client) + return client, err +} + +func (a *admin) putClient(ctx context.Context, id string, client map[string]any) error { + _, err := a.do(ctx, http.MethodPut, "/clients/"+url.PathEscape(id), client, nil) + return err +} + +func (a *admin) createClient(ctx context.Context, client clientRep) (string, error) { + location, err := a.do(ctx, http.MethodPost, "/clients", client, nil) + if err != nil { + return "", err + } + return created(location), nil +} + +func (a *admin) deleteClient(ctx context.Context, id string) error { + _, err := a.do(ctx, http.MethodDelete, "/clients/"+url.PathEscape(id), nil, nil) + return err +} + +func (a *admin) serviceAccountUser(ctx context.Context, clientID string) (userRep, error) { + var user userRep + _, err := a.do(ctx, http.MethodGet, "/clients/"+url.PathEscape(clientID)+"/service-account-user", nil, &user) + return user, err +} + +func (a *admin) clientSecret(ctx context.Context, clientID string) (string, error) { + var secret struct { + Value string `json:"value"` + } + _, err := a.do(ctx, http.MethodGet, "/clients/"+url.PathEscape(clientID)+"/client-secret", nil, &secret) + return secret.Value, err +} + +func (a *admin) regenerateClientSecret(ctx context.Context, clientID string) (string, error) { + var secret struct { + Value string `json:"value"` + } + _, err := a.do(ctx, http.MethodPost, "/clients/"+url.PathEscape(clientID)+"/client-secret", nil, &secret) + return secret.Value, err +} + +func (a *admin) clientRoles(ctx context.Context, clientID string) ([]roleRep, error) { + var found []roleRep + _, err := a.do(ctx, http.MethodGet, "/clients/"+url.PathEscape(clientID)+"/roles", nil, &found) + return found, err +} + +func (a *admin) createClientRole(ctx context.Context, clientID, name string) error { + _, err := a.do(ctx, http.MethodPost, "/clients/"+url.PathEscape(clientID)+"/roles", roleRep{Name: name}, nil) + return err +} + +// roleUsers lists every user holding one client role, across the realm. +func (a *admin) roleUsers(ctx context.Context, clientID, role string) ([]userRep, error) { + var found []userRep + _, err := a.do(ctx, http.MethodGet, "/clients/"+url.PathEscape(clientID)+"/roles/"+url.PathEscape(role)+"/users?max=1000", nil, &found) + return found, err +} + +func (a *admin) userClientRoles(ctx context.Context, userID, clientID string) ([]roleRep, error) { + var found []roleRep + _, err := a.do(ctx, http.MethodGet, "/users/"+url.PathEscape(userID)+"/role-mappings/clients/"+url.PathEscape(clientID), nil, &found) + return found, err +} + +func (a *admin) addUserClientRoles(ctx context.Context, userID, clientID string, roles []roleRep) error { + _, err := a.do(ctx, http.MethodPost, "/users/"+url.PathEscape(userID)+"/role-mappings/clients/"+url.PathEscape(clientID), roles, nil) + return err +} + +func (a *admin) removeUserClientRoles(ctx context.Context, userID, clientID string, roles []roleRep) error { + if len(roles) == 0 { + return nil + } + _, err := a.do(ctx, http.MethodDelete, "/users/"+url.PathEscape(userID)+"/role-mappings/clients/"+url.PathEscape(clientID), roles, nil) + return err +} + +func (a *admin) protocolMappers(ctx context.Context, clientID string) ([]protocolMapperRep, error) { + var found []protocolMapperRep + _, err := a.do(ctx, http.MethodGet, "/clients/"+url.PathEscape(clientID)+"/protocol-mappers/models", nil, &found) + return found, err +} + +func (a *admin) addProtocolMapper(ctx context.Context, clientID string, mapper protocolMapperRep) error { + _, err := a.do(ctx, http.MethodPost, "/clients/"+url.PathEscape(clientID)+"/protocol-mappers/models", mapper, nil) + return err +} + +func (a *admin) realm(ctx context.Context) (map[string]any, error) { + var realm map[string]any + _, err := a.do(ctx, http.MethodGet, "", nil, &realm) + return realm, err +} + +func (a *admin) updateRealm(ctx context.Context, fields map[string]any) error { + _, err := a.do(ctx, http.MethodPut, "", fields, nil) + return err +} + +func (a *admin) userProfile(ctx context.Context) (map[string]any, error) { + var profile map[string]any + _, err := a.do(ctx, http.MethodGet, "/users/profile", nil, &profile) + return profile, err +} + +func (a *admin) updateUserProfile(ctx context.Context, profile map[string]any) error { + _, err := a.do(ctx, http.MethodPut, "/users/profile", profile, nil) + return err +} diff --git a/identity/keycloak/bootstrap.go b/identity/keycloak/bootstrap.go new file mode 100644 index 0000000..06c1d9a --- /dev/null +++ b/identity/keycloak/bootstrap.go @@ -0,0 +1,379 @@ +package keycloak + +import ( + "context" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + + "golang.org/x/oauth2" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// realmManagementRoles are the rights the app's service account needs on +// its realm: users, clients, and the realm's own settings. +var realmManagementRoles = []string{"manage-realm", "manage-users", "manage-clients", "view-realm", "view-users", "view-clients"} + +// ensureRealm creates the app's realm and client as the Keycloak admin when +// they are missing, and converges the client's secret on the configured one. +// The realm requires TLS only when the issuer itself is served over it. +func ensureRealm(ctx context.Context, server, realm string, opts Options) error { + token, err := adminToken(ctx, server, opts.AdminUsername, opts.AdminPassword) + if err != nil { + return err + } + client := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})) + realms := &admin{base: server + "/admin/realms", client: client} + if _, err := realms.do(ctx, http.MethodGet, "/"+url.PathEscape(realm), nil, nil); isStatus(err, http.StatusNotFound) { + sslRequired := "external" + if strings.HasPrefix(server, "http://") { + sslRequired = "none" + } + if _, err := realms.do(ctx, http.MethodPost, "", map[string]any{ + "realm": realm, + "enabled": true, + "sslRequired": sslRequired, + "organizationsEnabled": true, + }, nil); err != nil { + return err + } + } else if err != nil { + return err + } + a := &admin{base: server + "/admin/realms/" + realm, client: client} + found, err := a.clientByClientID(ctx, opts.ClientID) + if isStatus(err, http.StatusNotFound) { + id, err := a.createClient(ctx, clientRep{ + ClientID: opts.ClientID, + Secret: strings.TrimSpace(opts.ClientSecret), + Name: strings.ToUpper(opts.App[:1]) + opts.App[1:], + ServiceAccountsEnabled: true, + DirectAccessGrantsEnabled: true, + }) + if err != nil { + return fmt.Errorf("create client: %w", err) + } + found = clientRep{ID: id, ClientID: opts.ClientID} + } else if err != nil { + return fmt.Errorf("resolve client: %w", err) + } + current, err := a.clientSecret(ctx, found.ID) + if err != nil { + return fmt.Errorf("read client secret: %w", err) + } + if current != strings.TrimSpace(opts.ClientSecret) { + raw, err := a.getClientRaw(ctx, found.ID) + if err != nil { + return fmt.Errorf("read client: %w", err) + } + raw["secret"] = strings.TrimSpace(opts.ClientSecret) + if err := a.putClient(ctx, found.ID, raw); err != nil { + return fmt.Errorf("set client secret: %w", err) + } + } + user, err := a.serviceAccountUser(ctx, found.ID) + if err != nil { + return fmt.Errorf("resolve service account user: %w", err) + } + management, err := a.clientByClientID(ctx, "realm-management") + if err != nil { + return fmt.Errorf("resolve realm-management client: %w", err) + } + roles, err := a.clientRoles(ctx, management.ID) + if err != nil { + return fmt.Errorf("list realm-management roles: %w", err) + } + var grant []roleRep + for _, role := range roles { + if slices.Contains(realmManagementRoles, role.Name) { + grant = append(grant, role) + } + } + if err := a.addUserClientRoles(ctx, user.ID, management.ID, grant); err != nil { + return fmt.Errorf("grant realm management: %w", err) + } + return nil +} + +// adminToken signs the Keycloak admin in on the master realm. +func adminToken(ctx context.Context, server, username, password string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, tokenTimeout) + defer cancel() + form := url.Values{ + "grant_type": {"password"}, + "client_id": {"admin-cli"}, + "username": {username}, + "password": {password}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, server+"/realms/master/protocol/openid-connect/token", strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("%w: %w", errUnavailable, err) + } + defer func() { _ = res.Body.Close() }() + var token struct { + AccessToken string `json:"access_token"` + } + if res.StatusCode != http.StatusOK { + return "", fmt.Errorf("admin sign-in on master realm: status %d", res.StatusCode) + } + if err := decodeJSON(res.Body, &token); err != nil || token.AccessToken == "" { + return "", fmt.Errorf("admin sign-in on master realm: no token") + } + return token.AccessToken, nil +} + +// bootstrap converges the realm on the configuration the app needs and +// caches what later requests refer to. Every step is idempotent, so each +// boot lands on the same state. The client itself must already exist: it is +// the credential the app boots with. +func (p *Provider) bootstrap(ctx context.Context) error { + client, err := p.admin.clientByClientID(ctx, p.clientID) + if err != nil { + return fmt.Errorf("resolve client: %w", err) + } + p.client = client.ID + if err := p.ensureOrganizations(ctx); err != nil { + return err + } + if err := p.ensureRoles(ctx); err != nil { + return err + } + if err := p.ensureTokenShape(ctx, p.client); err != nil { + return err + } + return p.ensureUnmanagedAttributes(ctx) +} + +// ensureOrganizations turns the realm's organizations on; they are the +// tenants. +func (p *Provider) ensureOrganizations(ctx context.Context) error { + realm, err := p.admin.realm(ctx) + if err != nil { + return fmt.Errorf("read realm: %w", err) + } + if enabled, _ := realm["organizationsEnabled"].(bool); enabled { + return nil + } + if err := p.admin.updateRealm(ctx, map[string]any{"organizationsEnabled": true}); err != nil { + return fmt.Errorf("enable organizations: %w", err) + } + return nil +} + +// ensureRoles adds any app role the client is missing and caches +// their representations, which grants need. +func (p *Provider) ensureRoles(ctx context.Context) error { + existing, err := p.admin.clientRoles(ctx, p.client) + if err != nil { + return fmt.Errorf("list client roles: %w", err) + } + have := make(map[string]roleRep, len(existing)) + for _, role := range existing { + have[role.Name] = role + } + for _, key := range identity.RoleKeys() { + if _, ok := have[key]; ok { + continue + } + if err := p.admin.createClientRole(ctx, p.client, key); err != nil { + return fmt.Errorf("add client role %s: %w", key, err) + } + } + created, err := p.admin.clientRoles(ctx, p.client) + if err != nil { + return fmt.Errorf("list client roles: %w", err) + } + p.roles = make(map[string]roleRep, len(created)) + for _, role := range created { + p.roles[role.Name] = role + } + return nil +} + +// ensureTokenShape makes a client's tokens verifiable by the app: they name +// the app as an audience. The app's own client and every service account +// go through it. The organization membership is not part of the shape: +// Keycloak keeps that scope optional whatever a client's defaults say, so +// every grant the app makes or advertises requests it explicitly. +func (p *Provider) ensureTokenShape(ctx context.Context, clientID string) error { + mappers, err := p.admin.protocolMappers(ctx, clientID) + if err != nil { + return fmt.Errorf("list protocol mappers: %w", err) + } + want := audienceMapper(p.app, p.clientID) + hasAudience := false + for _, mapper := range mappers { + if mapper.ProtocolMapper == want.ProtocolMapper && mapper.Config["included.client.audience"] == p.clientID { + hasAudience = true + break + } + } + if !hasAudience { + if err := p.admin.addProtocolMapper(ctx, clientID, want); err != nil { + return fmt.Errorf("add audience mapper: %w", err) + } + } + return nil +} + +// ensureUnmanagedAttributes lets the app keep its own attributes on users, +// which invitations rely on. Keycloak drops attributes its user profile +// does not declare unless the realm allows administrators to set them. +func (p *Provider) ensureUnmanagedAttributes(ctx context.Context) error { + profile, err := p.admin.userProfile(ctx) + if err != nil { + return fmt.Errorf("read user profile: %w", err) + } + if policy, _ := profile["unmanagedAttributePolicy"].(string); policy == "ADMIN_EDIT" || policy == "ENABLED" { + return nil + } + profile["unmanagedAttributePolicy"] = "ADMIN_EDIT" + if err := p.admin.updateUserProfile(ctx, profile); err != nil { + return fmt.Errorf("allow unmanaged attributes: %w", err) + } + return nil +} + +// grantRole gives a user one app role, replacing any it held. +func (p *Provider) grantRole(ctx context.Context, userID, roleKey string) error { + held, err := p.admin.userClientRoles(ctx, userID, p.client) + if err != nil { + return err + } + if err := p.admin.removeUserClientRoles(ctx, userID, p.client, held); err != nil { + return err + } + role, ok := p.roles[roleKey] + if !ok { + return fmt.Errorf("unknown role %q", roleKey) + } + return p.admin.addUserClientRoles(ctx, userID, p.client, []roleRep{role}) +} + +// bootstrapTenant converges the tenant and whoever gets in first. Nothing +// changes when it is all already there. +func (p *Provider) bootstrapTenant(ctx context.Context, b Bootstrap) error { + tenant := alias(b.Tenant) + org, err := p.admin.organizationByAlias(ctx, tenant) + if isStatus(err, http.StatusNotFound) { + id, err := p.admin.createOrganization(ctx, organizationRep{Name: b.Tenant, Alias: tenant}) + if err != nil { + return fmt.Errorf("create organization: %w", err) + } + org = organizationRep{ID: id, Alias: tenant} + } else if err != nil { + return fmt.Errorf("resolve organization: %w", err) + } + if b.ClientID != "" { + if err := p.bootstrapServiceAccount(ctx, org, b); err != nil { + return err + } + } + if b.AdminEmail != "" { + return p.bootstrapAdmin(ctx, org, b) + } + return nil +} + +// bootstrapServiceAccount converges the admin service account that drives +// the tenant, with the secret the deployer chose. An SDK can then act on the +// deployment before any person has signed in. A secret rotated elsewhere is +// set back on the next boot, since the configured value is the one the +// deployer holds. +func (p *Provider) bootstrapServiceAccount(ctx context.Context, org organizationRep, b Bootstrap) error { + attributes := map[string]string{p.managedKey(): "true", p.tenantKey(): org.Alias} + client, err := p.admin.clientByClientID(ctx, b.ClientID) + if isStatus(err, http.StatusNotFound) { + id, err := p.admin.createClient(ctx, clientRep{ + ClientID: b.ClientID, + Secret: b.ClientSecret, + Name: b.ClientID, + ServiceAccountsEnabled: true, + Attributes: attributes, + }) + if err != nil { + return fmt.Errorf("create client: %w", err) + } + client = clientRep{ID: id, ClientID: b.ClientID, Attributes: attributes} + } else if err != nil { + return fmt.Errorf("resolve client: %w", err) + } + // A client the app did not create, or one that belongs to another + // tenant, is never taken over. + if client.Attributes[p.managedKey()] != "true" || client.Attributes[p.tenantKey()] != org.Alias { + return fmt.Errorf("client %q exists and is not this tenant's service account", b.ClientID) + } + current, err := p.admin.clientSecret(ctx, client.ID) + if err != nil { + return fmt.Errorf("read client secret: %w", err) + } + if current != b.ClientSecret { + raw, err := p.admin.getClientRaw(ctx, client.ID) + if err != nil { + return fmt.Errorf("read client: %w", err) + } + raw["secret"] = b.ClientSecret + if err := p.admin.putClient(ctx, client.ID, raw); err != nil { + return fmt.Errorf("set client secret: %w", err) + } + } + if err := p.ensureTokenShape(ctx, client.ID); err != nil { + return err + } + user, err := p.admin.serviceAccountUser(ctx, client.ID) + if err != nil { + return fmt.Errorf("resolve service account user: %w", err) + } + if err := p.admin.addMember(ctx, org.ID, user.ID); err != nil && !isStatus(err, http.StatusConflict) { + return fmt.Errorf("add member: %w", err) + } + if err := p.grantRole(ctx, user.ID, identity.RoleKey(authv1.Role_ROLE_ADMIN)); err != nil { + return fmt.Errorf("grant admin: %w", err) + } + return nil +} + +// bootstrapAdmin converges the person who administers the tenant with the +// password the deployer chose. A missing user is created signed-in-ready +// with the Admin role. An existing one is left alone: the members page owns +// the role from then on, and a password changed since is kept. +// +// The realm's user profile requires a name before anyone can sign in; the +// tenant supplies one until the admin is known by a better one. +func (p *Provider) bootstrapAdmin(ctx context.Context, org organizationRep, b Bootstrap) error { + _, err := p.admin.userByEmail(ctx, b.AdminEmail) + if err == nil { + return nil + } + if !isStatus(err, http.StatusNotFound) { + return fmt.Errorf("resolve admin: %w", err) + } + id, err := p.admin.createUser(ctx, userRep{ + Username: b.AdminEmail, + Email: b.AdminEmail, + FirstName: b.Tenant, + LastName: "Admin", + Enabled: true, + EmailVerified: true, + Credentials: []credentialRep{{Type: "password", Value: b.AdminPassword}}, + }) + if err != nil { + return fmt.Errorf("create admin: %w", err) + } + if err := p.admin.addMember(ctx, org.ID, id); err != nil { + return fmt.Errorf("add member: %w", p.discardUser(ctx, id, err)) + } + if err := p.grantRole(ctx, id, identity.RoleKey(authv1.Role_ROLE_ADMIN)); err != nil { + return fmt.Errorf("grant admin: %w", p.discardUser(ctx, id, err)) + } + return nil +} diff --git a/identity/keycloak/keycloak.go b/identity/keycloak/keycloak.go new file mode 100644 index 0000000..b3e5e89 --- /dev/null +++ b/identity/keycloak/keycloak.go @@ -0,0 +1,320 @@ +// Package keycloak adapts a Keycloak realm to the app's identity port. +// The app owns every screen and the AuthService contract; Keycloak stores +// credentials, issues tokens, and holds the organizations that are +// the app's tenants. The server is Keycloak's only client: browsers hold a +// session cookie and never learn the issuer, and service accounts mint +// client-credentials tokens against the realm. +// +// Human sign-in uses the direct access grant, so the app's own login page +// keeps working and nothing redirects to Keycloak. Anything Keycloak offers +// beyond a password, such as MFA or a federated login, is not reachable +// through it. +// +// Given the Keycloak admin's credentials, the app creates its realm and its +// own confidential client on boot; without them the realm's owners provide +// both, with the client's service account holding realm management rights. +// Everything else the app needs, it converges on boot either way: +// organizations, its roles on the client, the token shape, and the attribute +// policy invitations rely on. Keycloak 26 or newer. +// +// A bootstrap tenant is converged on boot with whoever gets in first: an +// admin service account for an SDK, a person with a password the deployer +// chose, or both. A bootstrap admin closes sign-up: registration is refused +// and everyone else is invited. +// +// It is a separate module so its OIDC dependencies stay out of the core +// module every connector builds against. +package keycloak + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + + "connectrpc.com/connect" + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" + "github.com/galaxy-io/modules/identity/auth/v1/authv1connect" +) + +// Options configures a Provider. +type Options struct { + // App names the app the provider serves, such as "billing". It + // prefixes the session cookie (_sid), the attributes kept on users + // and clients (.invite, .service_account, .tenant), and + // names what the provider creates for the app. + App string + // Issuer is the realm URL, the issuer its tokens carry. + Issuer string + // ClientID and ClientSecret identify the app's confidential client. Its + // service account administers the realm on the app's behalf, and every + // token the app accepts names it as an audience. + ClientID string + ClientSecret string + // UIOrigin is the origin the UI is served from; https marks the session + // cookie Secure. + UIOrigin string + // AdminUsername and AdminPassword, when set, are the Keycloak admin's + // credentials on the master realm. The app uses them once per boot to + // create its realm and client when they are missing. Both or neither. + AdminUsername string + AdminPassword string + // Bootstrap, when set, is a tenant converged on every boot with whoever + // gets in first. + Bootstrap Bootstrap +} + +// Bootstrap names a tenant and who gets in first: a service account that +// administers it, so an SDK can act before any person has signed in, a +// person who administers it with a password the deployer chose, or both. +type Bootstrap struct { + Tenant string + // ClientID and ClientSecret are the service account's; both or neither. + ClientID string + ClientSecret string + // AdminEmail and AdminPassword are the admin's; both or neither. Setting + // them closes sign-up. + AdminEmail string + AdminPassword string +} + +func (b Bootstrap) set() bool { + return b.Tenant != "" || b.ClientID != "" || b.ClientSecret != "" || b.AdminEmail != "" || b.AdminPassword != "" +} + +// complete reports whether the bootstrap names a tenant and at least one way +// into it. +func (b Bootstrap) complete() bool { + if b.Tenant == "" || (b.ClientID == "") != (b.ClientSecret == "") || (b.AdminEmail == "") != (b.AdminPassword == "") { + return false + } + return b.ClientID != "" || b.AdminEmail != "" +} + +// Provider implements the app's identity port against Keycloak. +type Provider struct { + authv1connect.UnimplementedAuthServiceHandler + + app string + issuer string + clientID string + clientSecret string + tokenEndpoint string + verifier *oidc.IDTokenVerifier + admin *admin + // client is the uuid Keycloak knows the app's client by. + client string + // roles caches the client's app roles by key; grants need the + // representation, not the name. + roles map[string]roleRep + // secureCookies marks the session cookie Secure when the UI is served + // over https. + secureCookies bool + // inviteOnly refuses registration: an admin was bootstrapped and people + // join by invitation. + inviteOnly bool + // callers caches session cookie -> cachedCaller so a browser's requests + // do not each round-trip to Keycloak. + callers sync.Map +} + +// New discovers the realm, proves the client credentials, converges the +// realm, and returns a provider ready to authenticate. +func New(ctx context.Context, opts Options) (*Provider, error) { + if opts.App == "" { + return nil, errors.New("keycloak: app is required") + } + if opts.Issuer == "" { + return nil, errors.New("keycloak: issuer is required") + } + if opts.ClientID == "" || opts.ClientSecret == "" { + return nil, errors.New("keycloak: client id and secret are required") + } + if opts.Bootstrap.set() && !opts.Bootstrap.complete() { + return nil, errors.New("keycloak: bootstrap needs a tenant and an admin email and password, a service account client id and secret, or both") + } + if (opts.AdminUsername == "") != (opts.AdminPassword == "") { + return nil, errors.New("keycloak: admin username and password go together") + } + issuer := strings.TrimRight(opts.Issuer, "/") + server, realmName, err := splitIssuer(issuer) + if err != nil { + return nil, err + } + adminBase := server + "/admin/realms/" + realmName + // The realm has to exist before discovery can find it. + if opts.AdminUsername != "" { + if err := ensureRealm(ctx, server, realmName, opts); err != nil { + return nil, fmt.Errorf("keycloak: create realm %q: %w", realmName, err) + } + } + realm, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return nil, fmt.Errorf("keycloak: discover %s: %w", issuer, err) + } + credentials := clientcredentials.Config{ + ClientID: opts.ClientID, + ClientSecret: strings.TrimSpace(opts.ClientSecret), + TokenURL: realm.Endpoint().TokenURL, + AuthStyle: oauth2.AuthStyleInHeader, + } + // A wrong secret fails the boot, not the first request that needs it. + if _, err := credentials.Token(ctx); err != nil { + return nil, fmt.Errorf("keycloak: authenticate client %q: %w", opts.ClientID, err) + } + p := &Provider{ + app: opts.App, + issuer: issuer, + clientID: opts.ClientID, + clientSecret: credentials.ClientSecret, + tokenEndpoint: realm.Endpoint().TokenURL, + verifier: realm.Verifier(&oidc.Config{ClientID: opts.ClientID}), + // The token source outlives boot and refreshes on this context. + admin: &admin{base: adminBase, client: credentials.Client(context.WithoutCancel(ctx))}, + secureCookies: strings.HasPrefix(opts.UIOrigin, "https://"), + inviteOnly: opts.Bootstrap.AdminEmail != "", + } + if err := p.bootstrap(ctx); err != nil { + return nil, fmt.Errorf("keycloak bootstrap: %w", err) + } + if opts.Bootstrap.complete() { + if err := p.bootstrapTenant(ctx, opts.Bootstrap); err != nil { + return nil, fmt.Errorf("keycloak bootstrap tenant %q: %w", opts.Bootstrap.Tenant, err) + } + } + return p, nil +} + +// splitIssuer takes the realm's issuer apart into the server and the realm +// name, since Keycloak forms it as /realms/. +func splitIssuer(issuer string) (server, realm string, err error) { + u, err := url.Parse(issuer) + if err != nil { + return "", "", fmt.Errorf("keycloak: parse issuer %q: %w", issuer, err) + } + prefix, realm, ok := strings.Cut(u.Path, "/realms/") + if !ok || realm == "" || strings.Contains(realm, "/") { + return "", "", fmt.Errorf("keycloak: issuer %q is not a realm URL (/realms/)", issuer) + } + u.Path = prefix + return strings.TrimRight(u.String(), "/"), realm, nil +} + +// Authenticate resolves the caller behind a request. Service accounts carry +// a bearer access token verified against the realm's JWKS; browsers carry +// the session cookie Login set. +func (p *Provider) Authenticate(ctx context.Context, header http.Header) (identity.Caller, error) { + if bearer, ok := strings.CutPrefix(header.Get("Authorization"), "Bearer "); ok && bearer != "" { + return p.authenticateToken(ctx, bearer) + } + if session, ok := p.sessionFromCookie(header); ok { + return p.authenticateSession(ctx, session) + } + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("no credentials")) +} + +// claims are the parts of a Keycloak access token the app reads. The +// organization scope lists the caller's memberships by alias; the client +// roles mapper puts granted roles under the client. +type claims struct { + Subject string `json:"sub"` + Organizations []string `json:"organization"` + ResourceAccess map[string]struct { + Roles []string `json:"roles"` + } `json:"resource_access"` +} + +// authenticateToken verifies an access token and resolves the caller's +// tenant and roles from its claims. The organization alias is the tenant's +// external id: Keycloak fixes it at creation and puts nothing else on the +// token. +func (p *Provider) authenticateToken(ctx context.Context, bearer string) (identity.Caller, error) { + token, err := p.verifier.Verify(ctx, bearer) + if err != nil { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, err) + } + var c claims + if err := token.Claims(&c); err != nil { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, err) + } + if len(c.Organizations) == 0 { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("token carries no organization; request the organization scope")) + } + if len(c.Organizations) > 1 { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("token carries more than one organization; request one with the organization: scope")) + } + caller := identity.Caller{ + UserID: c.Subject, + TenantExternalID: c.Organizations[0], + TenantName: c.Organizations[0], + } + for _, key := range c.ResourceAccess[p.clientID].Roles { + if role := identity.RoleFromKey(key); role != authv1.Role_ROLE_UNSPECIFIED { + caller.Roles = append(caller.Roles, role) + } + } + if len(caller.Roles) == 0 { + return identity.Caller{}, connect.NewError(connect.CodePermissionDenied, errors.New("token carries no "+p.app+" role")) + } + return caller, nil +} + +// GetAuthConfig tells the UI that auth is on and non-interactive clients +// what to request from the issuer. +func (p *Provider) GetAuthConfig(_ context.Context, _ *connect.Request[authv1.GetAuthConfigRequest]) (*connect.Response[authv1.GetAuthConfigResponse], error) { + return connect.NewResponse(&authv1.GetAuthConfigResponse{ + Issuer: p.issuer, + ServiceAccountScopes: []string{"openid", "organization"}, + InviteOnly: p.inviteOnly, + }), nil +} + +// adminCaller resolves the caller the interceptor stashed and requires +// tenant administration. +func adminCaller(ctx context.Context) (identity.Caller, error) { + c, ok := identity.CallerFrom(ctx) + if !ok { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + if !c.IsAdmin() { + return identity.Caller{}, connect.NewError(connect.CodePermissionDenied, errors.New("only admins can manage the team")) + } + return c, nil +} + +// tenant resolves the caller's organization, which admin requests are +// scoped to. +func (p *Provider) tenant(ctx context.Context, caller identity.Caller) (organizationRep, error) { + org, err := p.admin.organizationByAlias(ctx, caller.TenantExternalID) + if err != nil { + return organizationRep{}, rpcError(err) + } + return org, nil +} + +// rpcError maps a Keycloak failure onto the code a client should see. An +// outage keeps its own code so it is never mistaken for a rejection. +func rpcError(err error) error { + var cerr *connect.Error + if errors.As(err, &cerr) { + return err + } + if errors.Is(err, errUnavailable) { + return connect.NewError(connect.CodeUnavailable, err) + } + switch { + case isStatus(err, http.StatusNotFound): + return connect.NewError(connect.CodeNotFound, err) + case isStatus(err, http.StatusConflict), isStatus(err, http.StatusBadRequest): + return connect.NewError(connect.CodeInvalidArgument, err) + } + return connect.NewError(connect.CodeInternal, err) +} diff --git a/identity/keycloak/members.go b/identity/keycloak/members.go new file mode 100644 index 0000000..0155f96 --- /dev/null +++ b/identity/keycloak/members.go @@ -0,0 +1,284 @@ +package keycloak + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "connectrpc.com/connect" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// Invitations live on the invited user as attributes: the code's hash and +// its expiry. Keycloak's own invitation flow mails the code, and the app +// sends no mail. +const ( + inviteLifetime = 7 * 24 * time.Hour + // serviceAccountPrefix is how Keycloak names a client's service account + // user, which is how one is told apart from a human in a member list. + serviceAccountPrefix = "service-account-" +) + +func isServiceAccount(user userRep) bool { + return strings.HasPrefix(user.Username, serviceAccountPrefix) +} + +// rolesByUser maps every holder of an app role to the role they hold. +func (p *Provider) rolesByUser(ctx context.Context) (map[string]authv1.Role, error) { + roles := map[string]authv1.Role{} + for _, key := range identity.RoleKeys() { + holders, err := p.admin.roleUsers(ctx, p.client, key) + if err != nil { + return nil, err + } + for _, user := range holders { + roles[user.ID] = identity.RoleFromKey(key) + } + } + return roles, nil +} + +// ListMembers returns the tenant's users joined with the app role each +// holds. The organization's member list names them; a per-user read fills +// in the profile it leaves out. +func (p *Provider) ListMembers(ctx context.Context, _ *connect.Request[authv1.ListMembersRequest]) (*connect.Response[authv1.ListMembersResponse], error) { + caller, ok := identity.CallerFrom(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + org, err := p.tenant(ctx, caller) + if err != nil { + return nil, err + } + members, err := p.admin.members(ctx, org.ID) + if err != nil { + return nil, rpcError(err) + } + roles, err := p.rolesByUser(ctx) + if err != nil { + return nil, rpcError(err) + } + out := make([]*authv1.Member, 0, len(members)) + for _, member := range members { + if isServiceAccount(member) { + continue + } + user, err := p.admin.getUser(ctx, member.ID) + if err != nil { + return nil, rpcError(err) + } + out = append(out, &authv1.Member{ + UserId: user.ID, + Name: displayName(user), + Email: user.Email, + Role: roles[user.ID], + }) + } + return connect.NewResponse(&authv1.ListMembersResponse{ + Members: out, + CanManage: caller.IsAdmin(), + }), nil +} + +// requireMember confirms a user belongs to the caller's tenant, so one +// tenant can never reach another's members. +func (p *Provider) requireMember(ctx context.Context, org organizationRep, userID string) error { + orgs, err := p.admin.memberOrganizations(ctx, userID) + if err != nil { + if isStatus(err, 404) { + return connect.NewError(connect.CodeNotFound, errors.New("member not found")) + } + return rpcError(err) + } + for _, candidate := range orgs { + if candidate.ID == org.ID { + return nil + } + } + return connect.NewError(connect.CodeNotFound, errors.New("member not found")) +} + +// InviteMember creates a teammate in the caller's tenant and returns the +// code that redeems their invitation. Nothing is emailed; the admin shares +// the link. +func (p *Provider) InviteMember(ctx context.Context, req *connect.Request[authv1.InviteMemberRequest]) (*connect.Response[authv1.InviteMemberResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + m := req.Msg + if m.GetEmail() == "" || m.GetGivenName() == "" || m.GetFamilyName() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("email, given_name, and family_name are required")) + } + roleKey := identity.RoleKey(m.GetRole()) + if roleKey == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role is required")) + } + org, err := p.tenant(ctx, caller) + if err != nil { + return nil, err + } + var raw [32]byte + if _, err := rand.Read(raw[:]); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + code := hex.EncodeToString(raw[:]) + userID, err := p.admin.createUser(ctx, userRep{ + Username: m.GetEmail(), + Email: m.GetEmail(), + FirstName: m.GetGivenName(), + LastName: m.GetFamilyName(), + Enabled: true, + Attributes: map[string][]string{ + p.inviteHashKey(): {inviteHash(code)}, + p.inviteExpiresKey(): {strconv.FormatInt(time.Now().Add(inviteLifetime).Unix(), 10)}, + }, + }) + if err != nil { + return nil, rpcError(err) + } + if err := p.admin.addMember(ctx, org.ID, userID); err != nil { + return nil, rpcError(p.discardUser(ctx, userID, err)) + } + if err := p.grantRole(ctx, userID, roleKey); err != nil { + return nil, rpcError(p.discardUser(ctx, userID, err)) + } + return connect.NewResponse(&authv1.InviteMemberResponse{ + UserId: userID, + Code: code, + }), nil +} + +func inviteHash(code string) string { + sum := sha256.Sum256([]byte(code)) + return hex.EncodeToString(sum[:]) +} + +// discardUser deletes a user whose creation did not complete, so the email +// is free for a retry rather than locked behind a half-created account. It +// runs detached from the request so a dropped client still triggers cleanup. +// cause is returned as-is on success; a failed delete is joined onto it, since +// the admin then has to remove the user by hand. +func (p *Provider) discardUser(ctx context.Context, userID string, cause error) error { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if err := p.admin.deleteUser(ctx, userID); err != nil { + return errors.Join(cause, fmt.Errorf("cleanup of user %s failed, remove it manually: %w", userID, err)) + } + return cause +} + +// AcceptInvite redeems an invitation and sets the member's password. The +// code is the credential, which is why this RPC is public. +func (p *Provider) AcceptInvite(ctx context.Context, req *connect.Request[authv1.AcceptInviteRequest]) (*connect.Response[authv1.AcceptInviteResponse], error) { + m := req.Msg + if m.GetUserId() == "" || m.GetCode() == "" || m.GetPassword() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id, code, and password are required")) + } + invalid := connect.NewError(connect.CodeUnauthenticated, errors.New("invalid or expired invite")) + user, err := p.admin.getUserRaw(ctx, m.GetUserId()) + if err != nil { + if errors.Is(err, errUnavailable) { + return nil, connect.NewError(connect.CodeUnavailable, err) + } + return nil, invalid + } + attributes, _ := user["attributes"].(map[string]any) + hash := attribute(attributes, p.inviteHashKey()) + expires, _ := strconv.ParseInt(attribute(attributes, p.inviteExpiresKey()), 10, 64) + if hash == "" || subtle.ConstantTimeCompare([]byte(hash), []byte(inviteHash(m.GetCode()))) != 1 || time.Now().Unix() > expires { + return nil, invalid + } + if err := p.admin.resetPassword(ctx, m.GetUserId(), m.GetPassword()); err != nil { + // Password-policy failures surface with Keycloak's reason. + return nil, rpcError(err) + } + // The code is spent, and the address it went to is now proven. + delete(attributes, p.inviteHashKey()) + delete(attributes, p.inviteExpiresKey()) + user["attributes"] = attributes + user["emailVerified"] = true + if err := p.admin.putUser(ctx, m.GetUserId(), user); err != nil { + return nil, rpcError(err) + } + return connect.NewResponse(&authv1.AcceptInviteResponse{}), nil +} + +// attribute reads the first value of a user attribute as Keycloak returns +// them: a list per key. +func attribute(attributes map[string]any, key string) string { + values, _ := attributes[key].([]any) + if len(values) == 0 { + return "" + } + value, _ := values[0].(string) + return value +} + +// SetMemberRole reassigns a member's role. +func (p *Provider) SetMemberRole(ctx context.Context, req *connect.Request[authv1.SetMemberRoleRequest]) (*connect.Response[authv1.SetMemberRoleResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + m := req.Msg + if m.GetUserId() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id is required")) + } + roleKey := identity.RoleKey(m.GetRole()) + if roleKey == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role is required")) + } + org, err := p.tenant(ctx, caller) + if err != nil { + return nil, err + } + if err := p.requireMember(ctx, org, m.GetUserId()); err != nil { + return nil, err + } + if err := p.grantRole(ctx, m.GetUserId(), roleKey); err != nil { + return nil, rpcError(err) + } + return connect.NewResponse(&authv1.SetMemberRoleResponse{}), nil +} + +// RemoveMember deletes a member of the caller's tenant. Callers cannot +// remove themselves, and members of other tenants are unreachable. +func (p *Provider) RemoveMember(ctx context.Context, req *connect.Request[authv1.RemoveMemberRequest]) (*connect.Response[authv1.RemoveMemberResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + userID := req.Msg.GetUserId() + if userID == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id is required")) + } + if userID == caller.UserID { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("you cannot remove yourself")) + } + org, err := p.tenant(ctx, caller) + if err != nil { + return nil, err + } + if err := p.requireMember(ctx, org, userID); err != nil { + return nil, err + } + if err := p.admin.deleteUser(ctx, userID); err != nil { + return nil, rpcError(err) + } + return connect.NewResponse(&authv1.RemoveMemberResponse{}), nil +} + +// inviteHashKey and inviteExpiresKey are the user attributes an invitation +// lives in. +func (p *Provider) inviteHashKey() string { return p.app + ".invite" } +func (p *Provider) inviteExpiresKey() string { return p.app + ".invite_expires" } diff --git a/identity/keycloak/service_accounts.go b/identity/keycloak/service_accounts.go new file mode 100644 index 0000000..b151d6e --- /dev/null +++ b/identity/keycloak/service_accounts.go @@ -0,0 +1,243 @@ +package keycloak + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "net/url" + "strings" + + "connectrpc.com/connect" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// A service account is a confidential client of its own, minting +// client-credentials tokens. Its service-account user is a member of the +// tenant and holds the app role, so its tokens read like a person's. +// Attributes mark the clients the app manages and the tenant each belongs +// to. +func (p *Provider) serviceAccountClientPrefix() string { return p.app + "-sa-" } +func (p *Provider) managedKey() string { return p.app + ".service_account" } +func (p *Provider) tenantKey() string { return p.app + ".tenant" } + +// GetToken runs the client-credentials grant for a service account. Proxying +// it keeps the scopes and the issuer the server's business, so a client only +// ever holds its id and secret. +func (p *Provider) GetToken(ctx context.Context, req *connect.Request[authv1.GetTokenRequest]) (*connect.Response[authv1.GetTokenResponse], error) { + m := req.Msg + if m.GetClientId() == "" || m.GetClientSecret() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("client_id and client_secret are required")) + } + // The organization scope is what puts the tenant on the token; Keycloak + // never grants it by default. + token, err := p.grantAs(ctx, m.GetClientId(), m.GetClientSecret(), url.Values{ + "grant_type": {"client_credentials"}, + "scope": {"openid organization"}, + }) + if errors.Is(err, errGrantRejected) { + // Unknown clients and wrong secrets both land here; neither should + // tell the caller which it was. + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid credentials")) + } + if err != nil { + return nil, rpcError(err) + } + return connect.NewResponse(&authv1.GetTokenResponse{ + AccessToken: token.AccessToken, + ExpiresIn: token.ExpiresIn, + }), nil +} + +// managedServiceAccounts lists the clients the app manages for a tenant. +func (p *Provider) managedServiceAccounts(ctx context.Context, tenant string) ([]clientRep, error) { + found, err := p.admin.clients(ctx) + if err != nil { + return nil, err + } + var own []clientRep + for _, client := range found { + if client.Attributes[p.managedKey()] == "true" && client.Attributes[p.tenantKey()] == tenant { + own = append(own, client) + } + } + return own, nil +} + +// ListServiceAccounts returns the tenant's service accounts. Client secrets +// are only readable by an admin of the tenant through create and rotate and +// are not part of the response. +func (p *Provider) ListServiceAccounts(ctx context.Context, _ *connect.Request[authv1.ListServiceAccountsRequest]) (*connect.Response[authv1.ListServiceAccountsResponse], error) { + caller, ok := identity.CallerFrom(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + clients, err := p.managedServiceAccounts(ctx, caller.TenantExternalID) + if err != nil { + return nil, rpcError(err) + } + roles, err := p.rolesByUser(ctx) + if err != nil { + return nil, rpcError(err) + } + accounts := make([]*authv1.ServiceAccount, 0, len(clients)) + for _, client := range clients { + user, err := p.admin.serviceAccountUser(ctx, client.ID) + if err != nil { + return nil, rpcError(err) + } + accounts = append(accounts, &authv1.ServiceAccount{ + UserId: user.ID, + ClientId: client.ClientID, + Name: client.Name, + Description: client.Description, + Role: roles[user.ID], + }) + } + return connect.NewResponse(&authv1.ListServiceAccountsResponse{ + ServiceAccounts: accounts, + CanManage: caller.IsAdmin(), + }), nil +} + +// CreateServiceAccount creates a client for the account, puts its user in +// the tenant with the app role, and returns the client secret exactly +// once. +func (p *Provider) CreateServiceAccount(ctx context.Context, req *connect.Request[authv1.CreateServiceAccountRequest]) (*connect.Response[authv1.CreateServiceAccountResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + name := strings.TrimSpace(req.Msg.GetName()) + if name == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name is required")) + } + roleKey := identity.RoleKey(req.Msg.GetRole()) + if roleKey == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role is required")) + } + org, err := p.tenant(ctx, caller) + if err != nil { + return nil, err + } + var raw [8]byte + if _, err := rand.Read(raw[:]); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + description := strings.TrimSpace(req.Msg.GetDescription()) + clientID := p.serviceAccountClientPrefix() + hex.EncodeToString(raw[:]) + id, err := p.admin.createClient(ctx, clientRep{ + ClientID: clientID, + Name: name, + Description: description, + ServiceAccountsEnabled: true, + Attributes: map[string]string{p.managedKey(): "true", p.tenantKey(): caller.TenantExternalID}, + }) + if err != nil { + return nil, rpcError(err) + } + cleanup := func() { _ = p.admin.deleteClient(ctx, id) } + if err := p.ensureTokenShape(ctx, id); err != nil { + cleanup() + return nil, rpcError(err) + } + user, err := p.admin.serviceAccountUser(ctx, id) + if err != nil { + cleanup() + return nil, rpcError(err) + } + if err := p.admin.addMember(ctx, org.ID, user.ID); err != nil { + cleanup() + return nil, rpcError(err) + } + if err := p.grantRole(ctx, user.ID, roleKey); err != nil { + cleanup() + return nil, rpcError(err) + } + secret, err := p.admin.clientSecret(ctx, id) + if err != nil { + cleanup() + return nil, rpcError(err) + } + return connect.NewResponse(&authv1.CreateServiceAccountResponse{ + ServiceAccount: &authv1.ServiceAccount{ + UserId: user.ID, + ClientId: clientID, + Name: name, + Description: description, + Role: req.Msg.GetRole(), + }, + ClientSecret: secret, + }), nil +} + +// requireServiceAccount resolves the client behind a service account's user +// id, refusing users that are not the app-managed accounts of the caller's +// tenant. +func (p *Provider) requireServiceAccount(ctx context.Context, tenant, userID string) (clientRep, error) { + notFound := connect.NewError(connect.CodeNotFound, errors.New("service account not found")) + user, err := p.admin.getUser(ctx, userID) + if err != nil { + if errors.Is(err, errUnavailable) { + return clientRep{}, connect.NewError(connect.CodeUnavailable, err) + } + return clientRep{}, notFound + } + if !isServiceAccount(user) { + return clientRep{}, notFound + } + client, err := p.admin.clientByClientID(ctx, strings.TrimPrefix(user.Username, serviceAccountPrefix)) + if err != nil { + return clientRep{}, rpcError(err) + } + if client.Attributes[p.managedKey()] != "true" || client.Attributes[p.tenantKey()] != tenant { + return clientRep{}, notFound + } + return client, nil +} + +// RotateServiceAccountSecret invalidates the current client secret and returns +// its replacement exactly once. +func (p *Provider) RotateServiceAccountSecret(ctx context.Context, req *connect.Request[authv1.RotateServiceAccountSecretRequest]) (*connect.Response[authv1.RotateServiceAccountSecretResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + if req.Msg.GetUserId() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id is required")) + } + client, err := p.requireServiceAccount(ctx, caller.TenantExternalID, req.Msg.GetUserId()) + if err != nil { + return nil, err + } + secret, err := p.admin.regenerateClientSecret(ctx, client.ID) + if err != nil { + return nil, rpcError(err) + } + return connect.NewResponse(&authv1.RotateServiceAccountSecretResponse{ + ClientId: client.ClientID, + ClientSecret: secret, + }), nil +} + +// RemoveServiceAccount deletes a service account's client, and its user with +// it. +func (p *Provider) RemoveServiceAccount(ctx context.Context, req *connect.Request[authv1.RemoveServiceAccountRequest]) (*connect.Response[authv1.RemoveServiceAccountResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + if req.Msg.GetUserId() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id is required")) + } + client, err := p.requireServiceAccount(ctx, caller.TenantExternalID, req.Msg.GetUserId()) + if err != nil { + return nil, err + } + if err := p.admin.deleteClient(ctx, client.ID); err != nil { + return nil, rpcError(err) + } + return connect.NewResponse(&authv1.RemoveServiceAccountResponse{}), nil +} diff --git a/identity/keycloak/session.go b/identity/keycloak/session.go new file mode 100644 index 0000000..79486ea --- /dev/null +++ b/identity/keycloak/session.go @@ -0,0 +1,290 @@ +package keycloak + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "connectrpc.com/connect" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// Login exchanges the credentials for an offline refresh token and hands the +// browser that token in an HttpOnly cookie. The token is the credential every +// later RPC carries: each request mints a short-lived access token from it, +// so nothing about Keycloak reaches the browser. Offline tokens outlive the +// realm's SSO session, and Logout revokes them. +const ( + sessionLifetime = 30 * 24 * time.Hour + // callerCacheTTL bounds how long a resolved session skips Keycloak; role + // changes and logouts from elsewhere take effect within it. + callerCacheTTL = time.Minute + // sessionScopes asks for the organization the tenant comes from and an + // offline token the cookie can hold. + sessionScopes = "openid organization offline_access" +) + +// sessionCookie builds the session cookie; an empty value with a negative +// maxAge clears it. +func (p *Provider) sessionCookie(value string, maxAge int) *http.Cookie { + return &http.Cookie{ //nolint:gosec // Secure follows the UI origin's scheme so plain-http local dev keeps its cookie + Name: p.sessionCookieName(), + Value: value, + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + Secure: p.secureCookies, + SameSite: http.SameSiteLaxMode, + } +} + +// sessionFromCookie reads the refresh token off a request, reporting false +// when there is none. +func (p *Provider) sessionFromCookie(header http.Header) (string, bool) { + cookie, err := (&http.Request{Header: header}).Cookie(p.sessionCookieName()) + if err != nil || cookie.Value == "" { + return "", false + } + return cookie.Value, true +} + +// cachedCaller is a resolved session that skips Keycloak until expires. +type cachedCaller struct { + caller identity.Caller + expires time.Time +} + +// tokenResponse is the token endpoint's answer to a grant. +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int32 `json:"expires_in"` +} + +// errGrantRejected means the token endpoint refused the grant: wrong +// credentials, a revoked token, or an account that cannot sign in. +var errGrantRejected = errors.New("grant rejected") + +// tokenTimeout bounds each call to the token endpoint. +const tokenTimeout = 10 * time.Second + +// grant runs one grant against the token endpoint as the app's client. +func (p *Provider) grant(ctx context.Context, form url.Values) (tokenResponse, error) { + return p.grantAs(ctx, p.clientID, p.clientSecret, form) +} + +// grantAs runs one grant against the token endpoint as the given client. +func (p *Provider) grantAs(ctx context.Context, clientID, clientSecret string, form url.Values) (tokenResponse, error) { + ctx, cancel := context.WithTimeout(ctx, tokenTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.tokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return tokenResponse{}, err + } + req.SetBasicAuth(clientID, clientSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + res, err := http.DefaultClient.Do(req) + if err != nil { + return tokenResponse{}, fmt.Errorf("%w: %w", errUnavailable, err) + } + defer func() { _ = res.Body.Close() }() + raw, err := io.ReadAll(res.Body) + if err != nil { + return tokenResponse{}, fmt.Errorf("%w: %w", errUnavailable, err) + } + if res.StatusCode >= 500 { + return tokenResponse{}, fmt.Errorf("%w: token endpoint status %d", errUnavailable, res.StatusCode) + } + if res.StatusCode != http.StatusOK { + return tokenResponse{}, fmt.Errorf("%w: %s", errGrantRejected, reason(raw)) + } + var token tokenResponse + if err := json.Unmarshal(raw, &token); err != nil { + return tokenResponse{}, fmt.Errorf("keycloak: decode token: %w", err) + } + return token, nil +} + +// revoke invalidates a refresh token; best effort, the cookie goes away +// regardless. +func (p *Provider) revoke(ctx context.Context, refreshToken string) { + form := url.Values{"token": {refreshToken}, "token_type_hint": {"refresh_token"}} + ctx, cancel := context.WithTimeout(ctx, tokenTimeout) + defer cancel() + endpoint := strings.TrimSuffix(p.tokenEndpoint, "/token") + "/revoke" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return + } + req.SetBasicAuth(p.clientID, p.clientSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if res, err := http.DefaultClient.Do(req); err == nil { + _ = res.Body.Close() + } +} + +// authenticateSession resolves the caller behind a session cookie by minting +// an access token from its refresh token and reading that token's claims. +func (p *Provider) authenticateSession(ctx context.Context, refreshToken string) (identity.Caller, error) { + now := time.Now() + if cached, ok := p.callers.Load(refreshToken); ok && now.Before(cached.(cachedCaller).expires) { + return cached.(cachedCaller).caller, nil + } + token, err := p.grant(ctx, url.Values{"grant_type": {"refresh_token"}, "refresh_token": {refreshToken}}) + if err != nil { + if errors.Is(err, errUnavailable) { + return identity.Caller{}, connect.NewError(connect.CodeUnavailable, err) + } + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid session")) + } + caller, err := p.authenticateToken(ctx, token.AccessToken) + if err != nil { + return identity.Caller{}, err + } + p.rememberCaller(refreshToken, caller, now) + return caller, nil +} + +// rememberCaller caches a resolved session and drops any entries that have +// expired; logins are rare enough that the sweep costs nothing. +func (p *Provider) rememberCaller(key string, caller identity.Caller, now time.Time) { + p.callers.Range(func(k, value any) bool { + if !now.Before(value.(cachedCaller).expires) { + p.callers.Delete(k) + } + return true + }) + p.callers.Store(key, cachedCaller{caller: caller, expires: now.Add(callerCacheTTL)}) +} + +// Login checks the credentials with the direct access grant and answers with +// the cookie that carries the session. Proxying keeps Keycloak off the +// browser's origin and the flow in one place. +func (p *Provider) Login(ctx context.Context, req *connect.Request[authv1.LoginRequest]) (*connect.Response[authv1.LoginResponse], error) { + m := req.Msg + if m.GetLoginName() == "" || m.GetPassword() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("login_name and password are required")) + } + token, err := p.grant(ctx, url.Values{ + "grant_type": {"password"}, + "username": {m.GetLoginName()}, + "password": {m.GetPassword()}, + "scope": {sessionScopes}, + }) + if err != nil { + if errors.Is(err, errUnavailable) { + return nil, connect.NewError(connect.CodeUnavailable, err) + } + // Unknown users and wrong passwords both land here; neither should + // tell the caller which it was. + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid credentials")) + } + res := connect.NewResponse(&authv1.LoginResponse{}) + res.Header().Add("Set-Cookie", p.sessionCookie(token.RefreshToken, int(sessionLifetime.Seconds())).String()) + return res, nil +} + +// Logout revokes the session's token and clears its cookie. +func (p *Provider) Logout(ctx context.Context, req *connect.Request[authv1.LogoutRequest]) (*connect.Response[authv1.LogoutResponse], error) { + if session, ok := p.sessionFromCookie(req.Header()); ok { + p.callers.Delete(session) + p.revoke(ctx, session) + } + res := connect.NewResponse(&authv1.LogoutResponse{}) + res.Header().Add("Set-Cookie", p.sessionCookie("", -1).String()) + return res, nil +} + +// GetSession describes the caller behind the request for the UI's own +// account surfaces. Service accounts have no human profile and answer with +// their id alone. +func (p *Provider) GetSession(ctx context.Context, _ *connect.Request[authv1.GetSessionRequest]) (*connect.Response[authv1.GetSessionResponse], error) { + caller, ok := identity.CallerFrom(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + user, err := p.admin.getUser(ctx, caller.UserID) + if err != nil { + return nil, rpcError(err) + } + return connect.NewResponse(&authv1.GetSessionResponse{ + UserId: caller.UserID, + Name: displayName(user), + Email: user.Email, + }), nil +} + +func displayName(user userRep) string { + return strings.TrimSpace(user.FirstName + " " + user.LastName) +} + +// aliasChars is everything an organization alias cannot keep. +var aliasChars = regexp.MustCompile(`[^a-z0-9]+`) + +// alias derives an organization's alias from its name. Keycloak fixes the +// alias at creation, which is what makes it usable as the tenant id. +func alias(name string) string { + a := strings.Trim(aliasChars.ReplaceAllString(strings.ToLower(name), "-"), "-") + if a == "" { + return "org" + } + return a +} + +// Register creates an organization and its first admin: the app's +// self-service signup. The organization becomes the tenant. An invite-only +// deployment refuses. +func (p *Provider) Register(ctx context.Context, req *connect.Request[authv1.RegisterRequest]) (*connect.Response[authv1.RegisterResponse], error) { + if p.inviteOnly { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("sign-up is closed on this deployment; ask an admin for an invitation")) + } + m := req.Msg + if m.GetOrgName() == "" || m.GetGivenName() == "" || m.GetFamilyName() == "" || m.GetEmail() == "" || m.GetPassword() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("org_name, given_name, family_name, email, and password are required")) + } + orgID, err := p.admin.createOrganization(ctx, organizationRep{Name: m.GetOrgName(), Alias: alias(m.GetOrgName())}) + if err != nil { + if isStatus(err, http.StatusConflict) { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("an organization with that name already exists")) + } + return nil, rpcError(err) + } + // The app sends no mail, so the founder's address starts verified and + // they can sign in immediately. + userID, err := p.admin.createUser(ctx, userRep{ + Username: m.GetEmail(), + Email: m.GetEmail(), + FirstName: m.GetGivenName(), + LastName: m.GetFamilyName(), + Enabled: true, + EmailVerified: true, + Credentials: []credentialRep{{Type: "password", Value: m.GetPassword()}}, + }) + if err != nil { + // Duplicate emails and password-policy failures both land here + // with Keycloak's reason attached; the organization goes with them + // so the name is free for a retry. + _ = p.admin.deleteOrganization(ctx, orgID) + return nil, rpcError(err) + } + if err := p.admin.addMember(ctx, orgID, userID); err != nil { + return nil, rpcError(p.discardUser(ctx, userID, err)) + } + // The founder administers the tenant they just created. + if err := p.grantRole(ctx, userID, identity.RoleKey(authv1.Role_ROLE_ADMIN)); err != nil { + return nil, rpcError(p.discardUser(ctx, userID, err)) + } + return connect.NewResponse(&authv1.RegisterResponse{TenantId: alias(m.GetOrgName())}), nil +} + +// sessionCookieName is the cookie the browser holds its session in. +func (p *Provider) sessionCookieName() string { return p.app + "_sid" } diff --git a/identity/proto/auth/v1/members.proto b/identity/proto/auth/v1/members.proto new file mode 100644 index 0000000..7eb0619 --- /dev/null +++ b/identity/proto/auth/v1/members.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; + +package auth.v1; + +// Role is an app permission level. The vocabulary is the app's, not the +// provider's: an adapter maps whatever grants it stores onto these. +enum Role { + ROLE_UNSPECIFIED = 0; + ROLE_ADMIN = 1; + ROLE_CREATOR = 2; + ROLE_VIEWER = 3; +} + +// Member is one user of a tenant. +message Member { + string user_id = 1; + string name = 2; + string email = 3; + Role role = 4; +} + +message ListMembersRequest {} + +message ListMembersResponse { + repeated Member members = 1; + // can_manage reports whether the caller may invite, re-role, or remove. + // The UI renders read-only when false. + bool can_manage = 2; +} + +message InviteMemberRequest { + string email = 1; + string given_name = 2; + string family_name = 3; + Role role = 4; +} + +// InviteMemberResponse carries the invitation an admin shares out of band. +// Nothing is emailed. +message InviteMemberResponse { + string user_id = 1; + string code = 2; +} + +message SetMemberRoleRequest { + string user_id = 1; + Role role = 2; +} + +message SetMemberRoleResponse {} + +message RemoveMemberRequest { + string user_id = 1; +} + +message RemoveMemberResponse {} diff --git a/identity/proto/auth/v1/service.proto b/identity/proto/auth/v1/service.proto new file mode 100644 index 0000000..545d2f8 --- /dev/null +++ b/identity/proto/auth/v1/service.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +package auth.v1; + +import "auth/v1/members.proto"; +import "auth/v1/service_accounts.proto"; +import "auth/v1/session.proto"; + +// AuthService is an app's identity surface. It is optional: with no +// provider configured the server leaves it unimplemented and the API is +// unauthenticated. +// +// The session RPCs run before the caller holds a bearer token, so they are +// the server's only public procedures and carry no tenant_id — there is no +// tenant until sign-in completes. Everything else is authenticated and +// scoped to the caller's own tenant. +service AuthService { + // Session; pre-token, public. An empty issuer means auth is disabled. + rpc GetAuthConfig(GetAuthConfigRequest) returns (GetAuthConfigResponse); + rpc Login(LoginRequest) returns (LoginResponse); + rpc GetToken(GetTokenRequest) returns (GetTokenResponse); + rpc Logout(LogoutRequest) returns (LogoutResponse); + rpc Register(RegisterRequest) returns (RegisterResponse); + rpc AcceptInvite(AcceptInviteRequest) returns (AcceptInviteResponse); + + // Session; authenticated. The UI's sign-in check. + rpc GetSession(GetSessionRequest) returns (GetSessionResponse); + + // Members; authenticated tenant administration. + rpc ListMembers(ListMembersRequest) returns (ListMembersResponse); + rpc InviteMember(InviteMemberRequest) returns (InviteMemberResponse); + rpc SetMemberRole(SetMemberRoleRequest) returns (SetMemberRoleResponse); + rpc RemoveMember(RemoveMemberRequest) returns (RemoveMemberResponse); + + // Service accounts; authenticated tenant administration. Secrets are only + // returned by create and rotate and cannot be retrieved later. + rpc ListServiceAccounts(ListServiceAccountsRequest) returns (ListServiceAccountsResponse); + rpc CreateServiceAccount(CreateServiceAccountRequest) returns (CreateServiceAccountResponse); + rpc RotateServiceAccountSecret(RotateServiceAccountSecretRequest) returns (RotateServiceAccountSecretResponse); + rpc RemoveServiceAccount(RemoveServiceAccountRequest) returns (RemoveServiceAccountResponse); +} diff --git a/identity/proto/auth/v1/service_accounts.proto b/identity/proto/auth/v1/service_accounts.proto new file mode 100644 index 0000000..b12db5d --- /dev/null +++ b/identity/proto/auth/v1/service_accounts.proto @@ -0,0 +1,52 @@ +syntax = "proto3"; + +package auth.v1; + +import "auth/v1/members.proto"; + +// ServiceAccount is a non-human identity used by automation such as the +// app's CLI. Its client secret is never returned by list operations. +message ServiceAccount { + string user_id = 1; + string client_id = 2; + string name = 3; + string description = 4; + Role role = 5; +} + +message ListServiceAccountsRequest {} + +message ListServiceAccountsResponse { + repeated ServiceAccount service_accounts = 1; + bool can_manage = 2; +} + +message CreateServiceAccountRequest { + string name = 1; + string description = 2; + Role role = 3; +} + +// CreateServiceAccountResponse returns client_secret exactly once. The app +// does not persist it and Zitadel cannot retrieve it again. +message CreateServiceAccountResponse { + ServiceAccount service_account = 1; + string client_secret = 2; +} + +message RotateServiceAccountSecretRequest { + string user_id = 1; +} + +// RotateServiceAccountSecretResponse returns the replacement secret exactly +// once. The previous secret is invalid after rotation. +message RotateServiceAccountSecretResponse { + string client_id = 1; + string client_secret = 2; +} + +message RemoveServiceAccountRequest { + string user_id = 1; +} + +message RemoveServiceAccountResponse {} diff --git a/identity/proto/auth/v1/session.proto b/identity/proto/auth/v1/session.proto new file mode 100644 index 0000000..2b73940 --- /dev/null +++ b/identity/proto/auth/v1/session.proto @@ -0,0 +1,83 @@ +syntax = "proto3"; + +package auth.v1; + +message GetAuthConfigRequest {} + +// GetAuthConfigResponse tells clients how to authenticate. An empty issuer +// means no provider is configured: the UI renders without a session and every +// other AuthService RPC is unimplemented. +message GetAuthConfigResponse { + string issuer = 1; + // service_account_scopes are requested by non-interactive clients. The + // provider owns these values so clients do not need provider-specific + // project IDs or reserved scope knowledge. + repeated string service_account_scopes = 2; + // invite_only means sign-up is closed: Register is refused and the UI + // offers no way to create an organization. People join by invitation. + bool invite_only = 3; +} + +// LoginRequest carries the credentials the app's own login page collected. +// A successful login answers with an HttpOnly session cookie that every later +// RPC carries; nothing about the provider reaches the browser. +message LoginRequest { + string login_name = 1; + string password = 2; +} + +message LoginResponse {} + +// GetTokenRequest exchanges a service account's credentials for an access +// token. The server runs the grant against the provider, so SDKs and the +// CLI need no provider knowledge and no route to it. +message GetTokenRequest { + string client_id = 1; + string client_secret = 2; +} + +// GetTokenResponse carries a bearer token and its lifetime in seconds. +// Clients mint again as it nears expiry. +message GetTokenResponse { + string access_token = 1; + int32 expires_in = 2; +} + +// LogoutRequest ends the session and clears its cookie. +message LogoutRequest {} + +message LogoutResponse {} + +// GetSessionRequest resolves the caller behind the session cookie. It is the +// UI's sign-in check: unauthenticated means show the login page. +message GetSessionRequest {} + +message GetSessionResponse { + string user_id = 1; + string name = 2; + string email = 3; + string avatar_url = 4; +} + +// RegisterRequest creates a tenant and its first admin. +message RegisterRequest { + string org_name = 1; + string given_name = 2; + string family_name = 3; + string email = 4; + string password = 5; +} + +message RegisterResponse { + string tenant_id = 1; +} + +// AcceptInviteRequest redeems an invite and sets the new member's password. +// The code is the credential, which is why this RPC is public. +message AcceptInviteRequest { + string user_id = 1; + string code = 2; + string password = 3; +} + +message AcceptInviteResponse {} diff --git a/identity/proxy/proxy.go b/identity/proxy/proxy.go new file mode 100644 index 0000000..4c75c1d --- /dev/null +++ b/identity/proxy/proxy.go @@ -0,0 +1,62 @@ +// Package proxy trusts identity headers set by an authenticating gateway in +// front of an app. It verifies nothing itself: the gateway has already +// authenticated the person, and the deployment's network policy is what +// makes the headers trustworthy. Sign-in, members and service accounts live +// upstream, so this is an Authenticator and not a Provider. +package proxy + +import ( + "context" + "errors" + "net/http" + + "connectrpc.com/connect" + + "github.com/galaxy-io/modules/identity" +) + +// Headers the gateway sets. The gateway strips any copy a client sent. +const ( + // UserHeader carries the gateway's stable user id. + UserHeader = "X-Forwarded-User" + // TenantHeader carries the gateway's tenant id. + TenantHeader = "X-Forwarded-Tenant" + // ServiceHeader names a trusted service calling on its own behalf. A + // request carrying it needs no user header. + ServiceHeader = "X-Forwarded-Service" +) + +// Authenticator reads callers off trusted gateway headers. +type Authenticator struct { + tenant string +} + +// New returns an Authenticator. A non-empty tenant pins the deployment to +// that gateway tenant and refuses any other; empty serves whatever tenant +// the gateway names. +func New(tenant string) *Authenticator { + return &Authenticator{tenant: tenant} +} + +// Authenticate resolves the caller from the gateway headers. A missing +// tenant or user is Unauthenticated; a tenant other than the pinned one is +// PermissionDenied. +func (a *Authenticator) Authenticate(_ context.Context, header http.Header) (identity.Caller, error) { + tenant := header.Get(TenantHeader) + if tenant == "" { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("no tenant header")) + } + if a.tenant != "" && tenant != a.tenant { + return identity.Caller{}, connect.NewError(connect.CodePermissionDenied, errors.New("tenant is not served here")) + } + if service := header.Get(ServiceHeader); service != "" { + return identity.Caller{TenantExternalID: tenant, Service: service}, nil + } + user := header.Get(UserHeader) + if user == "" { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("no user header")) + } + return identity.Caller{UserID: user, TenantExternalID: tenant}, nil +} + +var _ identity.Authenticator = (*Authenticator)(nil) diff --git a/identity/proxy/proxy_test.go b/identity/proxy/proxy_test.go new file mode 100644 index 0000000..8040dec --- /dev/null +++ b/identity/proxy/proxy_test.go @@ -0,0 +1,70 @@ +package proxy + +import ( + "context" + "net/http" + "testing" + + "connectrpc.com/connect" +) + +func TestAuthenticateUnpinned(t *testing.T) { + caller, err := New("").Authenticate(context.Background(), http.Header{TenantHeader: {"tenant-2"}, UserHeader: {"u1"}}) + if err != nil { + t.Fatal(err) + } + if caller.TenantExternalID != "tenant-2" || caller.UserID != "u1" { + t.Fatalf("unexpected caller %+v", caller) + } +} + +func TestAuthenticate(t *testing.T) { + a := New("tenant-1") + for name, tc := range map[string]struct { + header http.Header + code connect.Code + user string + service string + }{ + "no tenant header": { + header: http.Header{UserHeader: {"u1"}}, + code: connect.CodeUnauthenticated, + }, + "wrong tenant": { + header: http.Header{TenantHeader: {"tenant-2"}, UserHeader: {"u1"}}, + code: connect.CodePermissionDenied, + }, + "no user header": { + header: http.Header{TenantHeader: {"tenant-1"}}, + code: connect.CodeUnauthenticated, + }, + "user caller": { + header: http.Header{TenantHeader: {"tenant-1"}, UserHeader: {"u1"}}, + user: "u1", + }, + "service caller": { + header: http.Header{TenantHeader: {"tenant-1"}, ServiceHeader: {"operator"}}, + service: "operator", + }, + "service wins over user": { + header: http.Header{TenantHeader: {"tenant-1"}, UserHeader: {"u1"}, ServiceHeader: {"operator"}}, + service: "operator", + }, + } { + t.Run(name, func(t *testing.T) { + caller, err := a.Authenticate(context.Background(), tc.header) + if tc.code != 0 { + if connect.CodeOf(err) != tc.code { + t.Fatalf("got %v, want code %v", err, tc.code) + } + return + } + if err != nil { + t.Fatal(err) + } + if caller.TenantExternalID != "tenant-1" || caller.UserID != tc.user || caller.Service != tc.service { + t.Fatalf("unexpected caller %+v", caller) + } + }) + } +} diff --git a/identity/zitadel/bootstrap.go b/identity/zitadel/bootstrap.go new file mode 100644 index 0000000..dc8d4ed --- /dev/null +++ b/identity/zitadel/bootstrap.go @@ -0,0 +1,226 @@ +package zitadel + +import ( + "context" + "fmt" + + "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/auth" + authorizationv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/authorization/v2" + objectv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/object/v2" + orgv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/org/v2" + projectv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/project/v2" + userv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/user/v2" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// projectRef is the app's project and the organization owning it. Other +// organizations need the project granted to them before their users can +// hold the app roles. +type projectRef struct { + id string + ownerOrgID string +} + +// bootstrap converges the instance on the configuration the app needs and +// caches the project on the provider. Every step is idempotent, so each boot +// lands on the same state. +func (p *Provider) bootstrap(ctx context.Context) error { + // The machine user's own organization owns the app's project. + me, err := p.api.AuthService().GetMyUser(ctx, &auth.GetMyUserRequest{}) + if err != nil { + return fmt.Errorf("resolve machine user: %w", err) + } + if err := p.ensureProject(ctx, me.GetUser().GetDetails().GetResourceOwner()); err != nil { + return err + } + return p.ensureProjectRoles(ctx) +} + +// ensureProject finds or creates the app's project. Role assertion puts +// granted roles in every token issued for it, which is how service accounts +// carry theirs. +func (p *Provider) ensureProject(ctx context.Context, homeOrgID string) error { + found, err := p.api.ProjectServiceV2().ListProjects(ctx, &projectv2.ListProjectsRequest{ + Filters: []*projectv2.ProjectSearchFilter{{ + Filter: &projectv2.ProjectSearchFilter_ProjectNameFilter{ + ProjectNameFilter: &projectv2.ProjectNameFilter{ProjectName: p.app}, + }, + }}, + }) + if err != nil { + return fmt.Errorf("list projects: %w", err) + } + if result := found.GetProjects(); len(result) > 0 { + p.project = projectRef{id: result[0].GetProjectId(), ownerOrgID: result[0].GetOrganizationId()} + return nil + } + created, err := p.api.ProjectServiceV2().CreateProject(ctx, &projectv2.CreateProjectRequest{ + OrganizationId: homeOrgID, + Name: p.app, + ProjectRoleAssertion: true, + }) + if err != nil { + return fmt.Errorf("create project: %w", err) + } + p.project = projectRef{id: created.GetProjectId(), ownerOrgID: homeOrgID} + return nil +} + +// ensureProjectRoles adds any app role the project is missing. +func (p *Provider) ensureProjectRoles(ctx context.Context) error { + existing, err := p.api.ProjectServiceV2().ListProjectRoles(ctx, &projectv2.ListProjectRolesRequest{ + ProjectId: p.project.id, + }) + if err != nil { + return fmt.Errorf("list project roles: %w", err) + } + have := make(map[string]bool, len(existing.GetProjectRoles())) + for _, role := range existing.GetProjectRoles() { + have[role.GetKey()] = true + } + for _, key := range identity.RoleKeys() { + if have[key] { + continue + } + if _, err := p.api.ProjectServiceV2().AddProjectRole(ctx, &projectv2.AddProjectRoleRequest{ + ProjectId: p.project.id, + RoleKey: key, + DisplayName: key, + }); err != nil { + return fmt.Errorf("add project role %s: %w", key, err) + } + } + return nil +} + +// ensureProjectGrant shares the app's project with an organization once, so +// its users can hold the app roles. The owning organization needs none. +func (p *Provider) ensureProjectGrant(ctx context.Context, orgID string) error { + if orgID == p.project.ownerOrgID { + return nil + } + if _, granted := p.projectGrants.Load(orgID); granted { + return nil + } + _, err := p.api.ProjectServiceV2().CreateProjectGrant(ctx, &projectv2.CreateProjectGrantRequest{ + ProjectId: p.project.id, + GrantedOrganizationId: orgID, + RoleKeys: identity.RoleKeys(), + }) + // An existing grant is the desired state, not a failure; a later + // authorization call surfaces anything genuinely broken. + p.projectGrants.Store(orgID, struct{}{}) + if err != nil && !isAlreadyExists(err) { + return err + } + return nil +} + +// grantRole gives a user one app role in their organization. +func (p *Provider) grantRole(ctx context.Context, orgID, userID, roleKey string) error { + if err := p.ensureProjectGrant(ctx, orgID); err != nil { + return err + } + _, err := p.api.AuthorizationServiceV2().CreateAuthorization(ctx, &authorizationv2.CreateAuthorizationRequest{ + UserId: userID, + ProjectId: p.project.id, + OrganizationId: orgID, + RoleKeys: []string{roleKey}, + }) + return err +} + +// isAlreadyExists reports whether an error means the resource the app was +// about to create is already there, which is the state it wanted anyway. +func isAlreadyExists(err error) bool { + return status.Code(err) == codes.AlreadyExists +} + +// isUnavailable reports whether an error means Zitadel could not be reached +// at all, which must never be mistaken for a rejected credential. +func isUnavailable(err error) bool { + switch status.Code(err) { + case codes.Unavailable, codes.DeadlineExceeded: + return true + } + return false +} + +// bootstrapTenant converges the tenant and the person invited to administer +// it. Nothing changes when it is all already there. +func (p *Provider) bootstrapTenant(ctx context.Context, b Bootstrap) error { + found, err := p.api.OrganizationServiceV2().ListOrganizations(ctx, &orgv2.ListOrganizationsRequest{ + Queries: []*orgv2.SearchQuery{{ + Query: &orgv2.SearchQuery_NameQuery{ + NameQuery: &orgv2.OrganizationNameQuery{Name: b.Tenant, Method: objectv2.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS}, + }, + }}, + }) + if err != nil { + return fmt.Errorf("list organizations: %w", err) + } + var orgID string + if result := found.GetResult(); len(result) > 0 { + orgID = result[0].GetId() + } else { + created, err := p.api.OrganizationServiceV2().AddOrganization(ctx, &orgv2.AddOrganizationRequest{Name: b.Tenant}) + if err != nil { + return fmt.Errorf("create organization: %w", err) + } + orgID = created.GetOrganizationId() + } + return p.bootstrapAdmin(ctx, orgID, b) +} + +// bootstrapAdmin converges the person who administers the tenant with the +// password the deployer chose. A missing user is created signed-in-ready +// with the Admin role. An existing one is left alone: the members page owns +// the role from then on, and a password changed since is kept. +// +// Zitadel requires a name on every human; the tenant supplies one until the +// admin is known by a better one. +func (p *Provider) bootstrapAdmin(ctx context.Context, orgID string, b Bootstrap) error { + users, err := p.api.UserServiceV2().ListUsers(ctx, &userv2.ListUsersRequest{ + Queries: []*userv2.SearchQuery{ + {Query: &userv2.SearchQuery_OrganizationIdQuery{ + OrganizationIdQuery: &userv2.OrganizationIdQuery{OrganizationId: orgID}, + }}, + {Query: &userv2.SearchQuery_EmailQuery{ + EmailQuery: &userv2.EmailQuery{EmailAddress: b.AdminEmail, Method: objectv2.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS}, + }}, + }, + }) + if err != nil { + return fmt.Errorf("resolve admin: %w", err) + } + if len(users.GetResult()) > 0 { + return nil + } + created, err := p.api.UserServiceV2().CreateUser(ctx, &userv2.CreateUserRequest{ + OrganizationId: orgID, + UserType: &userv2.CreateUserRequest_Human_{ + Human: &userv2.CreateUserRequest_Human{ + Profile: &userv2.SetHumanProfile{GivenName: b.Tenant, FamilyName: "Admin"}, + Email: &userv2.SetHumanEmail{ + Email: b.AdminEmail, + Verification: &userv2.SetHumanEmail_IsVerified{IsVerified: true}, + }, + PasswordType: &userv2.CreateUserRequest_Human_Password{ + Password: &userv2.Password{Password: b.AdminPassword}, + }, + }, + }, + }) + if err != nil { + return fmt.Errorf("create admin: %w", err) + } + if err := p.grantRole(ctx, orgID, created.GetId(), identity.RoleKey(authv1.Role_ROLE_ADMIN)); err != nil { + return fmt.Errorf("grant admin: %w", p.discardUser(ctx, created.GetId(), err)) + } + return nil +} diff --git a/identity/zitadel/members.go b/identity/zitadel/members.go new file mode 100644 index 0000000..0e24d00 --- /dev/null +++ b/identity/zitadel/members.go @@ -0,0 +1,228 @@ +package zitadel + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "connectrpc.com/connect" + authorizationv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/authorization/v2" + filterv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/filter/v2" + userv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/user/v2" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// ListMembers returns the tenant's users joined with the app role each +// holds. Both reads are scoped to the caller's own organization. +func (p *Provider) ListMembers(ctx context.Context, _ *connect.Request[authv1.ListMembersRequest]) (*connect.Response[authv1.ListMembersResponse], error) { + caller, ok := identity.CallerFrom(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + orgID := caller.TenantExternalID + + users, err := p.api.UserServiceV2().ListUsers(ctx, &userv2.ListUsersRequest{ + Queries: []*userv2.SearchQuery{ + {Query: &userv2.SearchQuery_OrganizationIdQuery{ + OrganizationIdQuery: &userv2.OrganizationIdQuery{OrganizationId: orgID}, + }}, + {Query: &userv2.SearchQuery_TypeQuery{ + TypeQuery: &userv2.TypeQuery{Type: userv2.Type_TYPE_HUMAN}, + }}, + }, + }) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + roles, err := p.rolesByUser(ctx, orgID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + members := make([]*authv1.Member, 0, len(users.GetResult())) + for _, user := range users.GetResult() { + human := user.GetHuman() + name := strings.TrimSpace(human.GetProfile().GetGivenName() + " " + human.GetProfile().GetFamilyName()) + members = append(members, &authv1.Member{ + UserId: user.GetUserId(), + Name: name, + Email: human.GetEmail().GetEmail(), + Role: roles[user.GetUserId()], + }) + } + return connect.NewResponse(&authv1.ListMembersResponse{ + Members: members, + CanManage: caller.IsAdmin(), + }), nil +} + +// rolesByUser maps each user in the organization to the app role their +// authorization grants. +func (p *Provider) rolesByUser(ctx context.Context, orgID string) (map[string]authv1.Role, error) { + granted, err := p.api.AuthorizationServiceV2().ListAuthorizations(ctx, &authorizationv2.ListAuthorizationsRequest{ + Filters: []*authorizationv2.AuthorizationsSearchFilter{ + {Filter: &authorizationv2.AuthorizationsSearchFilter_ProjectId{ProjectId: &filterv2.IDFilter{Id: p.project.id}}}, + {Filter: &authorizationv2.AuthorizationsSearchFilter_OrganizationId{OrganizationId: &filterv2.IDFilter{Id: orgID}}}, + }, + }) + if err != nil { + return nil, err + } + roles := make(map[string]authv1.Role, len(granted.GetAuthorizations())) + for _, authorization := range granted.GetAuthorizations() { + if role := grantedRole(authorization); role != authv1.Role_ROLE_UNSPECIFIED { + roles[authorization.GetUser().GetId()] = role + } + } + return roles, nil +} + +// grantedRole is the app role an authorization carries, unspecified +// when none of its keys is one app recognizes. +func grantedRole(authorization *authorizationv2.Authorization) authv1.Role { + for _, key := range authorization.GetRoles() { + if role := identity.RoleFromKey(key.GetKey()); role != authv1.Role_ROLE_UNSPECIFIED { + return role + } + } + return authv1.Role_ROLE_UNSPECIFIED +} + +// InviteMember creates a teammate in the caller's tenant and returns the +// code that redeems their invitation. Nothing is emailed; the admin shares +// the link. +func (p *Provider) InviteMember(ctx context.Context, req *connect.Request[authv1.InviteMemberRequest]) (*connect.Response[authv1.InviteMemberResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + m := req.Msg + if m.GetEmail() == "" || m.GetGivenName() == "" || m.GetFamilyName() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("email, given_name, and family_name are required")) + } + roleKey := identity.RoleKey(m.GetRole()) + if roleKey == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role is required")) + } + orgID := caller.TenantExternalID + + created, err := p.api.UserServiceV2().CreateUser(ctx, &userv2.CreateUserRequest{ + OrganizationId: orgID, + UserType: &userv2.CreateUserRequest_Human_{ + Human: &userv2.CreateUserRequest_Human{ + Profile: &userv2.SetHumanProfile{ + GivenName: m.GetGivenName(), + FamilyName: m.GetFamilyName(), + }, + Email: &userv2.SetHumanEmail{Email: m.GetEmail()}, + }, + }, + }) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + // returnCode hands the invitation back instead of mailing it. + invite, err := p.api.UserServiceV2().CreateInviteCode(ctx, &userv2.CreateInviteCodeRequest{ + UserId: created.GetId(), + Verification: &userv2.CreateInviteCodeRequest_ReturnCode{ReturnCode: &userv2.ReturnInviteCode{}}, + }) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, p.discardUser(ctx, created.GetId(), err)) + } + if err := p.grantRole(ctx, orgID, created.GetId(), roleKey); err != nil { + return nil, connect.NewError(connect.CodeInternal, p.discardUser(ctx, created.GetId(), err)) + } + return connect.NewResponse(&authv1.InviteMemberResponse{ + UserId: created.GetId(), + Code: invite.GetInviteCode(), + }), nil +} + +// discardUser deletes a user whose invitation did not complete, so the email +// is free for a retry rather than locked behind a half-created account. It +// runs detached from the request so a dropped client still triggers cleanup. +// cause is returned as-is on success; a failed delete is joined onto it, since +// the admin then has to remove the user by hand. +func (p *Provider) discardUser(ctx context.Context, userID string, cause error) error { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if _, err := p.api.UserServiceV2().DeleteUser(ctx, &userv2.DeleteUserRequest{UserId: userID}); err != nil { + return errors.Join(cause, fmt.Errorf("cleanup of user %s failed, remove it manually: %w", userID, err)) + } + return cause +} + +// SetMemberRole reassigns a member's role, creating the authorization when +// the member has none yet. +func (p *Provider) SetMemberRole(ctx context.Context, req *connect.Request[authv1.SetMemberRoleRequest]) (*connect.Response[authv1.SetMemberRoleResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + m := req.Msg + if m.GetUserId() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id is required")) + } + roleKey := identity.RoleKey(m.GetRole()) + if roleKey == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role is required")) + } + orgID := caller.TenantExternalID + + // Scoping the lookup to the caller's organization is what keeps one + // tenant from re-roling another's members. + existing, err := p.api.AuthorizationServiceV2().ListAuthorizations(ctx, &authorizationv2.ListAuthorizationsRequest{ + Filters: []*authorizationv2.AuthorizationsSearchFilter{ + {Filter: &authorizationv2.AuthorizationsSearchFilter_ProjectId{ProjectId: &filterv2.IDFilter{Id: p.project.id}}}, + {Filter: &authorizationv2.AuthorizationsSearchFilter_OrganizationId{OrganizationId: &filterv2.IDFilter{Id: orgID}}}, + {Filter: &authorizationv2.AuthorizationsSearchFilter_InUserIds{InUserIds: &filterv2.InIDsFilter{Ids: []string{m.GetUserId()}}}}, + }, + }) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + if found := existing.GetAuthorizations(); len(found) > 0 { + if _, err := p.api.AuthorizationServiceV2().UpdateAuthorization(ctx, &authorizationv2.UpdateAuthorizationRequest{ + Id: found[0].GetId(), + RoleKeys: []string{roleKey}, + }); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&authv1.SetMemberRoleResponse{}), nil + } + if err := p.grantRole(ctx, orgID, m.GetUserId(), roleKey); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&authv1.SetMemberRoleResponse{}), nil +} + +// RemoveMember deletes a member of the caller's tenant. Callers cannot +// remove themselves, and members of other tenants are unreachable. +func (p *Provider) RemoveMember(ctx context.Context, req *connect.Request[authv1.RemoveMemberRequest]) (*connect.Response[authv1.RemoveMemberResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + userID := req.Msg.GetUserId() + if userID == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id is required")) + } + if userID == caller.UserID { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("you cannot remove yourself")) + } + target, err := p.api.UserServiceV2().GetUserByID(ctx, &userv2.GetUserByIDRequest{UserId: userID}) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, errors.New("member not found")) + } + if target.GetUser().GetDetails().GetResourceOwner() != caller.TenantExternalID { + return nil, connect.NewError(connect.CodeNotFound, errors.New("member not found")) + } + if _, err := p.api.UserServiceV2().DeleteUser(ctx, &userv2.DeleteUserRequest{UserId: userID}); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&authv1.RemoveMemberResponse{}), nil +} diff --git a/identity/zitadel/service_accounts.go b/identity/zitadel/service_accounts.go new file mode 100644 index 0000000..53673cd --- /dev/null +++ b/identity/zitadel/service_accounts.go @@ -0,0 +1,238 @@ +package zitadel + +import ( + "context" + "errors" + "strings" + "time" + + "connectrpc.com/connect" + filterv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/filter/v2" + metadatav2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/metadata/v2" + userv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/user/v2" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// managedServiceAccountKey is the metadata key marking the machine users the +// app manages. +func (p *Provider) managedServiceAccountKey() string { return p.app + ".service_account" } + +// tokenTimeout bounds each grant against the token endpoint. +const tokenTimeout = 10 * time.Second + +// GetToken runs the client-credentials grant for a service account. Proxying +// it keeps the scopes and the issuer the server's business, so a client only +// ever holds its id and secret. +func (p *Provider) GetToken(ctx context.Context, req *connect.Request[authv1.GetTokenRequest]) (*connect.Response[authv1.GetTokenResponse], error) { + m := req.Msg + if m.GetClientId() == "" || m.GetClientSecret() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("client_id and client_secret are required")) + } + ctx, cancel := context.WithTimeout(ctx, tokenTimeout) + defer cancel() + config := clientcredentials.Config{ + ClientID: m.GetClientId(), + ClientSecret: m.GetClientSecret(), + TokenURL: p.tokenEndpoint, + Scopes: p.serviceAccountScopes(), + AuthStyle: oauth2.AuthStyleInHeader, + } + token, err := config.Token(ctx) + if err != nil { + var refused *oauth2.RetrieveError + if errors.As(err, &refused) { + // Unknown clients and wrong secrets both land here; neither + // should tell the caller which it was. + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid credentials")) + } + return nil, connect.NewError(connect.CodeUnavailable, err) + } + return connect.NewResponse(&authv1.GetTokenResponse{ + AccessToken: token.AccessToken, + ExpiresIn: int32(time.Until(token.Expiry).Seconds()), + }), nil +} + +// ListServiceAccounts returns the machine users in the caller's organization. +// Client secrets are never readable after creation and are not part of the +// response. +func (p *Provider) ListServiceAccounts(ctx context.Context, _ *connect.Request[authv1.ListServiceAccountsRequest]) (*connect.Response[authv1.ListServiceAccountsResponse], error) { + caller, ok := identity.CallerFrom(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + orgID := caller.TenantExternalID + users, err := p.api.UserServiceV2().ListUsers(ctx, &userv2.ListUsersRequest{ + Queries: p.managedServiceAccountQueries(orgID), + }) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + roles, err := p.rolesByUser(ctx, orgID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + accounts := make([]*authv1.ServiceAccount, 0, len(users.GetResult())) + for _, user := range users.GetResult() { + machine := user.GetMachine() + accounts = append(accounts, &authv1.ServiceAccount{ + UserId: user.GetUserId(), + ClientId: user.GetUserId(), + Name: machine.GetName(), + Description: machine.GetDescription(), + Role: roles[user.GetUserId()], + }) + } + return connect.NewResponse(&authv1.ListServiceAccountsResponse{ + ServiceAccounts: accounts, + CanManage: caller.IsAdmin(), + }), nil +} + +// CreateServiceAccount creates a JWT machine user, grants its app role, +// and returns the client secret exactly once. +func (p *Provider) CreateServiceAccount(ctx context.Context, req *connect.Request[authv1.CreateServiceAccountRequest]) (*connect.Response[authv1.CreateServiceAccountResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + name := strings.TrimSpace(req.Msg.GetName()) + if name == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name is required")) + } + roleKey := identity.RoleKey(req.Msg.GetRole()) + if roleKey == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role is required")) + } + description := strings.TrimSpace(req.Msg.GetDescription()) + created, err := p.api.UserServiceV2().CreateUser(ctx, &userv2.CreateUserRequest{ + OrganizationId: caller.TenantExternalID, + Metadata: []*userv2.Metadata{{ + Key: p.managedServiceAccountKey(), + Value: []byte("true"), + }}, + UserType: &userv2.CreateUserRequest_Machine_{ + Machine: &userv2.CreateUserRequest_Machine{ + Name: name, + Description: &description, + AccessTokenType: userv2.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, + }, + }, + }) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + userID := created.GetId() + cleanup := func() { + _, _ = p.api.UserServiceV2().DeleteUser(ctx, &userv2.DeleteUserRequest{UserId: userID}) + } + secret, err := p.api.UserServiceV2().AddSecret(ctx, &userv2.AddSecretRequest{UserId: userID}) + if err != nil { + cleanup() + return nil, connect.NewError(connect.CodeInternal, err) + } + if err := p.grantRole(ctx, caller.TenantExternalID, userID, roleKey); err != nil { + cleanup() + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&authv1.CreateServiceAccountResponse{ + ServiceAccount: &authv1.ServiceAccount{ + UserId: userID, + ClientId: userID, + Name: name, + Description: description, + Role: req.Msg.GetRole(), + }, + ClientSecret: secret.GetClientSecret(), + }), nil +} + +// RotateServiceAccountSecret invalidates the current client secret and returns +// its replacement exactly once. +func (p *Provider) RotateServiceAccountSecret(ctx context.Context, req *connect.Request[authv1.RotateServiceAccountSecretRequest]) (*connect.Response[authv1.RotateServiceAccountSecretResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + userID := req.Msg.GetUserId() + if userID == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id is required")) + } + if err := p.requireServiceAccount(ctx, caller.TenantExternalID, userID); err != nil { + return nil, err + } + if _, err := p.api.UserServiceV2().RemoveSecret(ctx, &userv2.RemoveSecretRequest{UserId: userID}); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + secret, err := p.api.UserServiceV2().AddSecret(ctx, &userv2.AddSecretRequest{UserId: userID}) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&authv1.RotateServiceAccountSecretResponse{ + ClientId: userID, + ClientSecret: secret.GetClientSecret(), + }), nil +} + +// RemoveServiceAccount deletes a machine user in the caller's organization. +func (p *Provider) RemoveServiceAccount(ctx context.Context, req *connect.Request[authv1.RemoveServiceAccountRequest]) (*connect.Response[authv1.RemoveServiceAccountResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + userID := req.Msg.GetUserId() + if userID == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id is required")) + } + if err := p.requireServiceAccount(ctx, caller.TenantExternalID, userID); err != nil { + return nil, err + } + if _, err := p.api.UserServiceV2().DeleteUser(ctx, &userv2.DeleteUserRequest{UserId: userID}); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&authv1.RemoveServiceAccountResponse{}), nil +} + +func (p *Provider) requireServiceAccount(ctx context.Context, orgID, userID string) error { + users, err := p.api.UserServiceV2().ListUsers(ctx, &userv2.ListUsersRequest{ + Queries: p.managedServiceAccountQueries(orgID, userID), + }) + if err != nil { + return connect.NewError(connect.CodeInternal, err) + } + if len(users.GetResult()) == 0 { + return connect.NewError(connect.CodeNotFound, errors.New("service account not found")) + } + return nil +} + +// managedServiceAccountQueries excludes Zitadel's own bootstrap/admin machine +// users. Only identities created by the app carry this marker and can be +// listed, rotated, or removed through the application. +func (p *Provider) managedServiceAccountQueries(orgID string, userIDs ...string) []*userv2.SearchQuery { + queries := []*userv2.SearchQuery{ + {Query: &userv2.SearchQuery_OrganizationIdQuery{ + OrganizationIdQuery: &userv2.OrganizationIdQuery{OrganizationId: orgID}, + }}, + {Query: &userv2.SearchQuery_TypeQuery{ + TypeQuery: &userv2.TypeQuery{Type: userv2.Type_TYPE_MACHINE}, + }}, + {Query: &userv2.SearchQuery_MetadataKeyFilter{ + MetadataKeyFilter: &metadatav2.MetadataKeyFilter{ + Key: p.managedServiceAccountKey(), + Method: filterv2.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS, + }, + }}, + } + if len(userIDs) > 0 { + queries = append(queries, &userv2.SearchQuery{Query: &userv2.SearchQuery_InUserIdsQuery{ + InUserIdsQuery: &userv2.InUserIDQuery{UserIds: userIDs}, + }}) + } + return queries +} diff --git a/identity/zitadel/session.go b/identity/zitadel/session.go new file mode 100644 index 0000000..03bca69 --- /dev/null +++ b/identity/zitadel/session.go @@ -0,0 +1,303 @@ +package zitadel + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "connectrpc.com/connect" + authorizationv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/authorization/v2" + filterv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/filter/v2" + orgv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/org/v2" + sessionv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/session/v2" + userv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/user/v2" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// Login creates a Zitadel session and hands the browser its id and token in +// an HttpOnly cookie. That cookie is the credential every later RPC carries, +// so nothing about Zitadel reaches the browser. The cookie and the session +// share a lifetime, so both expire together. +const ( + sessionLifetime = 30 * 24 * time.Hour + // callerCacheTTL bounds how long a resolved session skips Zitadel; role + // changes and logouts from elsewhere take effect within it. + callerCacheTTL = time.Minute +) + +// sessionRef identifies a Zitadel session together with the token that +// authorizes acting on it. +type sessionRef struct { + id string + token string +} + +func (s sessionRef) String() string { return s.id + "." + s.token } + +// sessionCookie builds the session cookie; an empty value with a negative +// maxAge clears it. +func (p *Provider) sessionCookie(value string, maxAge int) *http.Cookie { + return &http.Cookie{ //nolint:gosec // Secure follows the UI origin's scheme so plain-http local dev keeps its cookie + Name: p.sessionCookieName(), + Value: value, + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + Secure: p.secureCookies, + SameSite: http.SameSiteLaxMode, + } +} + +// sessionFromCookie reads the session off a request, reporting false when +// there is none. The token is required: the machine user could read any +// session by id alone, so a cookie without one must never be honored. +func (p *Provider) sessionFromCookie(header http.Header) (sessionRef, bool) { + cookie, err := (&http.Request{Header: header}).Cookie(p.sessionCookieName()) + if err != nil { + return sessionRef{}, false + } + id, token, ok := strings.Cut(cookie.Value, ".") + if !ok || id == "" || token == "" { + return sessionRef{}, false + } + return sessionRef{id: id, token: token}, true +} + +// cachedCaller is a resolved session that skips Zitadel until expires. +type cachedCaller struct { + caller identity.Caller + expires time.Time +} + +// authenticateSession resolves the caller behind a session cookie: Zitadel +// validates the token, the session names the user and organization, and an +// authorization lookup supplies the roles. +func (p *Provider) authenticateSession(ctx context.Context, ref sessionRef) (identity.Caller, error) { + now := time.Now() + if cached, ok := p.callers.Load(ref); ok && now.Before(cached.(cachedCaller).expires) { + return cached.(cachedCaller).caller, nil + } + found, err := p.api.SessionServiceV2().GetSession(ctx, &sessionv2.GetSessionRequest{ + SessionId: ref.id, + SessionToken: &ref.token, + }) + if err != nil { + if isUnavailable(err) { + return identity.Caller{}, connect.NewError(connect.CodeUnavailable, err) + } + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid session")) + } + session := found.GetSession() + if expiry := session.GetExpirationDate(); expiry != nil && !now.Before(expiry.AsTime()) { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("session expired")) + } + user := session.GetFactors().GetUser() + if user.GetId() == "" || user.GetOrganizationId() == "" { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("session carries no user")) + } + roles, err := p.userRoles(ctx, user.GetId()) + if err != nil { + if isUnavailable(err) { + return identity.Caller{}, connect.NewError(connect.CodeUnavailable, err) + } + return identity.Caller{}, connect.NewError(connect.CodeInternal, err) + } + caller := identity.Caller{ + UserID: user.GetId(), + TenantExternalID: user.GetOrganizationId(), + Roles: roles, + } + p.rememberCaller(ref, caller, now) + return caller, nil +} + +// rememberCaller caches a resolved session and drops any entries that have +// expired; logins are rare enough that the sweep costs nothing. +func (p *Provider) rememberCaller(ref sessionRef, caller identity.Caller, now time.Time) { + p.callers.Range(func(key, value any) bool { + if !now.Before(value.(cachedCaller).expires) { + p.callers.Delete(key) + } + return true + }) + p.callers.Store(ref, cachedCaller{caller: caller, expires: now.Add(callerCacheTTL)}) +} + +// userRoles returns the app roles granted to one user in the project. +func (p *Provider) userRoles(ctx context.Context, userID string) ([]authv1.Role, error) { + granted, err := p.api.AuthorizationServiceV2().ListAuthorizations(ctx, &authorizationv2.ListAuthorizationsRequest{ + Filters: []*authorizationv2.AuthorizationsSearchFilter{ + {Filter: &authorizationv2.AuthorizationsSearchFilter_ProjectId{ProjectId: &filterv2.IDFilter{Id: p.project.id}}}, + {Filter: &authorizationv2.AuthorizationsSearchFilter_InUserIds{InUserIds: &filterv2.InIDsFilter{Ids: []string{userID}}}}, + }, + }) + if err != nil { + return nil, err + } + var roles []authv1.Role + for _, authorization := range granted.GetAuthorizations() { + if role := grantedRole(authorization); role != authv1.Role_ROLE_UNSPECIFIED { + roles = append(roles, role) + } + } + return roles, nil +} + +// Login checks the credentials as a Zitadel session and answers with the +// cookie that carries it. Proxying keeps Zitadel off the browser's origin +// and the flow in one place. +func (p *Provider) Login(ctx context.Context, req *connect.Request[authv1.LoginRequest]) (*connect.Response[authv1.LoginResponse], error) { + m := req.Msg + if m.GetLoginName() == "" || m.GetPassword() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("login_name and password are required")) + } + + created, err := p.api.SessionServiceV2().CreateSession(ctx, &sessionv2.CreateSessionRequest{ + Checks: &sessionv2.Checks{ + User: &sessionv2.CheckUser{Search: &sessionv2.CheckUser_LoginName{LoginName: m.GetLoginName()}}, + Password: &sessionv2.CheckPassword{ + Password: m.GetPassword(), + }, + }, + Lifetime: durationpb.New(sessionLifetime), + }) + if err != nil { + if isUnavailable(err) { + return nil, connect.NewError(connect.CodeUnavailable, err) + } + // Unknown users and wrong passwords both land here; neither should + // tell the caller which it was. + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid credentials")) + } + + session := sessionRef{id: created.GetSessionId(), token: created.GetSessionToken()} + res := connect.NewResponse(&authv1.LoginResponse{}) + res.Header().Add("Set-Cookie", p.sessionCookie(session.String(), int(sessionLifetime.Seconds())).String()) + return res, nil +} + +// Logout ends the session and clears its cookie. Terminating the session in +// Zitadel is best effort: the cookie goes away regardless. +func (p *Provider) Logout(ctx context.Context, req *connect.Request[authv1.LogoutRequest]) (*connect.Response[authv1.LogoutResponse], error) { + if session, ok := p.sessionFromCookie(req.Header()); ok { + p.callers.Delete(session) + _, _ = p.api.SessionServiceV2().DeleteSession(ctx, &sessionv2.DeleteSessionRequest{ + SessionId: session.id, + SessionToken: &session.token, + }) + } + res := connect.NewResponse(&authv1.LogoutResponse{}) + res.Header().Add("Set-Cookie", p.sessionCookie("", -1).String()) + return res, nil +} + +// GetSession describes the caller behind the request for the UI's own +// account surfaces. Machine users have no human profile and answer with +// their id alone. +func (p *Provider) GetSession(ctx context.Context, _ *connect.Request[authv1.GetSessionRequest]) (*connect.Response[authv1.GetSessionResponse], error) { + caller, ok := identity.CallerFrom(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + found, err := p.api.UserServiceV2().GetUserByID(ctx, &userv2.GetUserByIDRequest{UserId: caller.UserID}) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + human := found.GetUser().GetHuman() + return connect.NewResponse(&authv1.GetSessionResponse{ + UserId: caller.UserID, + Name: human.GetProfile().GetDisplayName(), + Email: human.GetEmail().GetEmail(), + AvatarUrl: human.GetProfile().GetAvatarUrl(), + }), nil +} + +// Register creates an organization and its first admin: the app's +// self-service signup. The organization becomes the tenant. An invite-only +// deployment refuses. +func (p *Provider) Register(ctx context.Context, req *connect.Request[authv1.RegisterRequest]) (*connect.Response[authv1.RegisterResponse], error) { + if p.inviteOnly { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("sign-up is closed on this deployment; ask an admin for an invitation")) + } + m := req.Msg + if m.GetOrgName() == "" || m.GetGivenName() == "" || m.GetFamilyName() == "" || m.GetEmail() == "" || m.GetPassword() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("org_name, given_name, family_name, email, and password are required")) + } + + created, err := p.api.OrganizationServiceV2().AddOrganization(ctx, &orgv2.AddOrganizationRequest{ + Name: m.GetOrgName(), + Admins: []*orgv2.AddOrganizationRequest_Admin{{ + UserType: &orgv2.AddOrganizationRequest_Admin_Human{ + Human: &userv2.AddHumanUserRequest{ + Profile: &userv2.SetHumanProfile{ + GivenName: m.GetGivenName(), + FamilyName: m.GetFamilyName(), + }, + // The app sends no mail, so the founder's address + // starts verified and they can sign in immediately. + Email: &userv2.SetHumanEmail{ + Email: m.GetEmail(), + Verification: &userv2.SetHumanEmail_IsVerified{IsVerified: true}, + }, + PasswordType: &userv2.AddHumanUserRequest_Password{ + Password: &userv2.Password{Password: m.GetPassword()}, + }, + }, + }, + }}, + }) + if err != nil { + // Duplicate names and password-policy failures both land here with + // Zitadel's reason attached. + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + // The founder administers the tenant they just created. + orgID := created.GetOrganizationId() + adminRole := identity.RoleKey(authv1.Role_ROLE_ADMIN) + for _, admin := range created.GetCreatedAdmins() { + if err := p.grantRole(ctx, orgID, admin.GetUserId(), adminRole); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + } + return connect.NewResponse(&authv1.RegisterResponse{TenantId: orgID}), nil +} + +// AcceptInvite redeems an invitation and sets the member's password. The +// code is the credential, which is why this RPC is public. +func (p *Provider) AcceptInvite(ctx context.Context, req *connect.Request[authv1.AcceptInviteRequest]) (*connect.Response[authv1.AcceptInviteResponse], error) { + m := req.Msg + if m.GetUserId() == "" || m.GetCode() == "" || m.GetPassword() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id, code, and password are required")) + } + if _, err := p.api.UserServiceV2().VerifyInviteCode(ctx, &userv2.VerifyInviteCodeRequest{ + UserId: m.GetUserId(), + VerificationCode: m.GetCode(), + }); err != nil { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid or expired invite")) + } + if _, err := p.api.UserServiceV2().UpdateUser(ctx, &userv2.UpdateUserRequest{ + UserId: m.GetUserId(), + UserType: &userv2.UpdateUserRequest_Human_{ + Human: &userv2.UpdateUserRequest_Human{ + Password: &userv2.SetPassword{ + PasswordType: &userv2.SetPassword_Password{ + Password: &userv2.Password{Password: m.GetPassword(), ChangeRequired: false}, + }, + }, + }, + }, + }); err != nil { + // Password-policy failures surface with Zitadel's reason. + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + return connect.NewResponse(&authv1.AcceptInviteResponse{}), nil +} + +// sessionCookieName is the cookie the browser holds its session in. +func (p *Provider) sessionCookieName() string { return p.app + "_sid" } diff --git a/identity/zitadel/zitadel.go b/identity/zitadel/zitadel.go new file mode 100644 index 0000000..cac8fac --- /dev/null +++ b/identity/zitadel/zitadel.go @@ -0,0 +1,256 @@ +// Package zitadel adapts a self-hosted Zitadel instance to the app's +// identity port. The app owns every screen and the AuthService contract; +// Zitadel stores credentials, holds sessions, and mints the tokens service +// accounts use. The server is Zitadel's only client: browsers hold a session +// cookie and never learn the issuer. +// +// A bootstrap tenant is converged on boot with a person who administers it +// with a password the deployer chose, which closes sign-up: registration is +// refused and everyone else is invited. +// +// It is a separate module so its gRPC and SDK dependencies stay out of the +// core module every connector builds against. +package zitadel + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + + "connectrpc.com/connect" + oidcclient "github.com/zitadel/oidc/v3/pkg/client" + "github.com/zitadel/zitadel-go/v3/pkg/authorization" + zitadeloauth "github.com/zitadel/zitadel-go/v3/pkg/authorization/oauth" + zclient "github.com/zitadel/zitadel-go/v3/pkg/client" + "github.com/zitadel/zitadel-go/v3/pkg/zitadel" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// Zitadel puts the user's organization on the token when the client requests +// the resourceowner scope. The organization is the tenant. +const ( + claimOrgID = "urn:zitadel:iam:user:resourceowner:id" + claimOrgName = "urn:zitadel:iam:user:resourceowner:name" +) + +// Options configures a Provider. +type Options struct { + // App names the app the provider serves, such as "billing". It + // prefixes the session cookie (_sid), the attributes kept on service + // accounts (.service_account), and names the Zitadel project. + App string + // Issuer is the Zitadel base URL. + Issuer string + // PAT is a machine-user personal access token holding instance rights. + PAT string + // UIOrigin is the origin the UI is served from; https marks the session + // cookie Secure. + UIOrigin string + // Bootstrap, when set, is a tenant converged on every boot with the + // person who administers it. + Bootstrap Bootstrap +} + +// Bootstrap names a tenant and the person who administers it, with the +// password the deployer chose. All fields or none. +type Bootstrap struct { + Tenant string + AdminEmail string + AdminPassword string +} + +func (b Bootstrap) set() bool { return b.Tenant != "" || b.AdminEmail != "" || b.AdminPassword != "" } + +func (b Bootstrap) complete() bool { + return b.Tenant != "" && b.AdminEmail != "" && b.AdminPassword != "" +} + +// Provider implements the app's identity port against Zitadel. +type Provider struct { + app string + issuer string + // tokenEndpoint is discovered once; GetToken runs every grant against it. + tokenEndpoint string + api *zclient.Client + verifier authorization.Verifier[*zitadeloauth.IntrospectionContext] + project projectRef + // rolesClaim is precomputed; every token-authenticated request reads it. + rolesClaim string + // secureCookies marks the session cookie Secure when the UI is served + // over https. + secureCookies bool + // inviteOnly refuses registration: an admin was bootstrapped and people + // join by invitation. + inviteOnly bool + // callers caches sessionRef -> cachedCaller so a browser's requests do + // not each round-trip to Zitadel. + callers sync.Map + // projectGrants caches org id -> project grant id. + projectGrants sync.Map +} + +// New provisions the app's project in Zitadel and returns a provider ready +// to authenticate. Provisioning is idempotent: every boot converges the same +// instance state. +func New(ctx context.Context, opts Options) (*Provider, error) { + if opts.App == "" { + return nil, errors.New("zitadel: app is required") + } + if opts.Issuer == "" { + return nil, errors.New("zitadel: issuer is required") + } + if opts.PAT == "" { + return nil, errors.New("zitadel: personal access token is required") + } + if opts.Bootstrap.set() && !opts.Bootstrap.complete() { + return nil, errors.New("zitadel: bootstrap needs a tenant, an admin email, and an admin password") + } + issuer := strings.TrimRight(opts.Issuer, "/") + target, err := dialTarget(issuer) + if err != nil { + return nil, err + } + api, err := zclient.New(ctx, target, zclient.WithAuth(zclient.PAT(strings.TrimSpace(opts.PAT)))) + if err != nil { + return nil, fmt.Errorf("zitadel: connect %s: %w", issuer, err) + } + + discovered, err := oidcclient.Discover(ctx, issuer, http.DefaultClient) + if err != nil { + _ = api.Close() + return nil, fmt.Errorf("zitadel: discover %s: %w", issuer, err) + } + p := &Provider{ + app: opts.App, + issuer: issuer, + tokenEndpoint: discovered.TokenEndpoint, + api: api, + secureCookies: strings.HasPrefix(opts.UIOrigin, "https://"), + inviteOnly: opts.Bootstrap.AdminEmail != "", + } + if err := p.bootstrap(ctx); err != nil { + _ = api.Close() + return nil, fmt.Errorf("zitadel bootstrap: %w", err) + } + if opts.Bootstrap.set() { + if err := p.bootstrapTenant(ctx, opts.Bootstrap); err != nil { + _ = api.Close() + return nil, fmt.Errorf("zitadel bootstrap tenant %q: %w", opts.Bootstrap.Tenant, err) + } + } + verifier, err := zitadeloauth.WithJWT(p.project.id, http.DefaultClient)(ctx, target) + if err != nil { + _ = api.Close() + return nil, fmt.Errorf("initialize access-token verifier for %s: %w", issuer, err) + } + p.verifier = verifier + p.rolesClaim = "urn:zitadel:iam:org:project:" + p.project.id + ":roles" + return p, nil +} + +// dialTarget turns the issuer URL into the SDK's host description, keeping +// plaintext local instances working. +func dialTarget(issuer string) (*zitadel.Zitadel, error) { + u, err := url.Parse(issuer) + if err != nil { + return nil, fmt.Errorf("zitadel: parse issuer %q: %w", issuer, err) + } + if u.Scheme == "https" { + return zitadel.New(u.Hostname()), nil + } + port := u.Port() + if port == "" { + port = "80" + } + return zitadel.New(u.Hostname(), zitadel.WithInsecure(port)), nil +} + +// Close releases the provider's connection to Zitadel. +func (p *Provider) Close() error { return p.api.Close() } + +// Authenticate resolves the caller behind a request. Service accounts carry +// a bearer access token verified against the issuer's JWKS; browsers carry +// the session cookie Login set. +func (p *Provider) Authenticate(ctx context.Context, header http.Header) (identity.Caller, error) { + if bearer, ok := strings.CutPrefix(header.Get("Authorization"), "Bearer "); ok && bearer != "" { + return p.authenticateToken(ctx, bearer) + } + if session, ok := p.sessionFromCookie(header); ok { + return p.authenticateSession(ctx, session) + } + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("no credentials")) +} + +// authenticateToken verifies an access token and resolves the caller's +// tenant and roles from its claims. +func (p *Provider) authenticateToken(ctx context.Context, bearer string) (identity.Caller, error) { + token, err := p.verifier.CheckAuthorization(ctx, "Bearer "+bearer) + if err != nil { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, err) + } + claims := token.Claims + orgID, _ := claims[claimOrgID].(string) + if orgID == "" { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("token carries no organization; request the resourceowner scope")) + } + orgName, _ := claims[claimOrgName].(string) + userID, _ := claims["sub"].(string) + + caller := identity.Caller{ + UserID: userID, + TenantExternalID: orgID, + TenantName: orgName, + } + if granted, ok := claims[p.rolesClaim].(map[string]any); ok { + caller.Roles = make([]authv1.Role, 0, len(granted)) + for key := range granted { + if role := identity.RoleFromKey(key); role != authv1.Role_ROLE_UNSPECIFIED { + caller.Roles = append(caller.Roles, role) + } + } + } + if len(caller.Roles) == 0 { + return identity.Caller{}, connect.NewError(connect.CodePermissionDenied, errors.New("token carries no "+p.app+" role")) + } + return caller, nil +} + +// GetAuthConfig tells the UI that auth is on and non-interactive clients +// what to request from the issuer. +func (p *Provider) GetAuthConfig(_ context.Context, _ *connect.Request[authv1.GetAuthConfigRequest]) (*connect.Response[authv1.GetAuthConfigResponse], error) { + return connect.NewResponse(&authv1.GetAuthConfigResponse{ + Issuer: p.issuer, + ServiceAccountScopes: p.serviceAccountScopes(), + InviteOnly: p.inviteOnly, + }), nil +} + +// serviceAccountScopes put the organization, the app's audience, and the +// caller's roles on a client-credentials token. +func (p *Provider) serviceAccountScopes() []string { + return []string{ + "openid", + "urn:zitadel:iam:user:resourceowner", + "urn:zitadel:iam:org:project:id:" + p.project.id + ":aud", + "urn:zitadel:iam:org:projects:roles", + } +} + +// adminCaller resolves the caller the interceptor stashed and requires +// tenant administration. +func adminCaller(ctx context.Context) (identity.Caller, error) { + c, ok := identity.CallerFrom(ctx) + if !ok { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + if !c.IsAdmin() { + return identity.Caller{}, connect.NewError(connect.CodePermissionDenied, errors.New("only admins can manage the team")) + } + return c, nil +} diff --git a/release-please-config.json b/release-please-config.json index 1214952..4b801e5 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -5,6 +5,9 @@ "release-type": "go", "tag-separator": "/", "packages": { + "identity": { + "component": "identity" + }, "secret": { "component": "secret" }