diff --git a/console/rbac/rbac.go b/console/rbac/rbac.go index 0ae013b..772c5e5 100644 --- a/console/rbac/rbac.go +++ b/console/rbac/rbac.go @@ -180,6 +180,93 @@ func (gm *GroupMapping) CheckAccess(userGroups, allowedRoles []string, permissio ) } +// RoleFromString converts a role name string to a Role constant using case-insensitive matching. +// Returns RoleUnspecified for unknown or empty strings. +func RoleFromString(s string) Role { + switch strings.ToLower(s) { + case "viewer": + return RoleViewer + case "editor": + return RoleEditor + case "owner": + return RoleOwner + default: + return RoleUnspecified + } +} + +// CheckAccessSharing verifies access using per-user sharing, per-group sharing, +// and legacy allowed-roles. The highest role found across all three sources is used. +// +// Evaluation order: +// 1. Check shareUsers for userEmail (case-insensitive) +// 2. Check shareGroups for any of userGroups (case-insensitive) +// 3. Legacy: check allowedRoles via GroupMapping.CheckAccess +// +// Returns nil if access is granted, or a PermissionDenied error otherwise. +func (gm *GroupMapping) CheckAccessSharing( + userEmail string, + userGroups []string, + shareUsers map[string]string, + shareGroups map[string]string, + allowedRoles []string, + permission Permission, +) error { + bestLevel := -1 + + // 1. Check per-user sharing grants + if shareUsers != nil { + emailLower := strings.ToLower(userEmail) + for email, roleName := range shareUsers { + if strings.ToLower(email) == emailLower { + role := RoleFromString(roleName) + if level := roleLevel[role]; level > bestLevel { + bestLevel = level + } + } + } + } + + // 2. Check per-group sharing grants + if shareGroups != nil { + for _, ug := range userGroups { + ugLower := strings.ToLower(ug) + for group, roleName := range shareGroups { + if strings.ToLower(group) == ugLower { + role := RoleFromString(roleName) + if level := roleLevel[role]; level > bestLevel { + bestLevel = level + } + } + } + } + } + + // 3. Legacy fallback: check allowedRoles via existing mechanism + if len(allowedRoles) > 0 { + if gm.CheckAccess(userGroups, allowedRoles, permission) == nil { + return nil + } + } + + // Evaluate best role from sharing sources + if bestLevel > 0 { + // Find the Role with this level + for role, level := range roleLevel { + if level == bestLevel { + if HasPermission(role, permission) { + return nil + } + } + } + } + + return connect.NewError( + connect.CodePermissionDenied, + fmt.Errorf("RBAC: authorization denied"), + ) +} + // defaultMapping is the package-level default GroupMapping using built-in group names. var defaultMapping = NewGroupMapping(nil, nil, nil) diff --git a/console/rbac/rbac_test.go b/console/rbac/rbac_test.go index ba60bc7..05fe694 100644 --- a/console/rbac/rbac_test.go +++ b/console/rbac/rbac_test.go @@ -484,3 +484,167 @@ func TestCheckAccess(t *testing.T) { } }) } + +func TestRoleFromString(t *testing.T) { + tests := []struct { + input string + want Role + }{ + {"viewer", RoleViewer}, + {"editor", RoleEditor}, + {"owner", RoleOwner}, + {"VIEWER", RoleViewer}, + {"Editor", RoleEditor}, + {"OWNER", RoleOwner}, + {"", RoleUnspecified}, + {"unknown", RoleUnspecified}, + {"admin", RoleUnspecified}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := RoleFromString(tt.input) + if got != tt.want { + t.Errorf("RoleFromString(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestCheckAccessSharing(t *testing.T) { + gm := NewGroupMapping(nil, nil, nil) + + t.Run("user email match grants access", func(t *testing.T) { + err := gm.CheckAccessSharing( + "alice@example.com", + []string{}, + map[string]string{"alice@example.com": "viewer"}, + nil, + nil, + PermissionSecretsRead, + ) + if err != nil { + t.Errorf("expected access granted via email, got: %v", err) + } + }) + + t.Run("user email match is case-insensitive", func(t *testing.T) { + err := gm.CheckAccessSharing( + "Alice@Example.COM", + []string{}, + map[string]string{"alice@example.com": "viewer"}, + nil, + nil, + PermissionSecretsRead, + ) + if err != nil { + t.Errorf("expected access granted via case-insensitive email, got: %v", err) + } + }) + + t.Run("group match in shareGroups grants access", func(t *testing.T) { + err := gm.CheckAccessSharing( + "bob@example.com", + []string{"platform-team"}, + nil, + map[string]string{"platform-team": "editor"}, + nil, + PermissionSecretsWrite, + ) + if err != nil { + t.Errorf("expected access granted via group share, got: %v", err) + } + }) + + t.Run("group match in shareGroups is case-insensitive", func(t *testing.T) { + err := gm.CheckAccessSharing( + "bob@example.com", + []string{"Platform-Team"}, + nil, + map[string]string{"platform-team": "viewer"}, + nil, + PermissionSecretsRead, + ) + if err != nil { + t.Errorf("expected access granted via case-insensitive group share, got: %v", err) + } + }) + + t.Run("legacy allowedRoles still works", func(t *testing.T) { + err := gm.CheckAccessSharing( + "carol@example.com", + []string{"viewer"}, + nil, + nil, + []string{"viewer"}, + PermissionSecretsRead, + ) + if err != nil { + t.Errorf("expected access granted via legacy allowedRoles, got: %v", err) + } + }) + + t.Run("highest role wins across all sources", func(t *testing.T) { + // User has viewer via email, but owner via group share + // Should allow delete (owner permission) + err := gm.CheckAccessSharing( + "alice@example.com", + []string{"ops"}, + map[string]string{"alice@example.com": "viewer"}, + map[string]string{"ops": "owner"}, + nil, + PermissionSecretsDelete, + ) + if err != nil { + t.Errorf("expected access granted via highest role, got: %v", err) + } + }) + + t.Run("denies when no source grants sufficient permission", func(t *testing.T) { + err := gm.CheckAccessSharing( + "alice@example.com", + []string{}, + map[string]string{"alice@example.com": "viewer"}, + nil, + nil, + PermissionSecretsWrite, + ) + if err == nil { + t.Fatal("expected PermissionDenied, got nil") + } + connectErr, ok := err.(*connect.Error) + if !ok { + t.Fatalf("expected *connect.Error, got %T", err) + } + if connectErr.Code() != connect.CodePermissionDenied { + t.Errorf("expected CodePermissionDenied, got %v", connectErr.Code()) + } + }) + + t.Run("denies when no grants at all", func(t *testing.T) { + err := gm.CheckAccessSharing( + "nobody@example.com", + []string{"unknown-group"}, + nil, + nil, + nil, + PermissionSecretsRead, + ) + if err == nil { + t.Fatal("expected PermissionDenied, got nil") + } + }) + + t.Run("empty sharing maps fall through to legacy", func(t *testing.T) { + err := gm.CheckAccessSharing( + "carol@example.com", + []string{"editor"}, + map[string]string{}, + map[string]string{}, + []string{"editor"}, + PermissionSecretsWrite, + ) + if err != nil { + t.Errorf("expected access granted via legacy fallback, got: %v", err) + } + }) +} diff --git a/console/secrets/authz.go b/console/secrets/authz.go index 32e31b2..829a01c 100644 --- a/console/secrets/authz.go +++ b/console/secrets/authz.go @@ -32,6 +32,32 @@ func CheckListAccess(gm *rbac.GroupMapping, userGroups, allowedRoles []string) e return gm.CheckAccess(userGroups, allowedRoles, rbac.PermissionSecretsList) } +// CheckReadAccessSharing verifies read permission using sharing-aware access control. +func CheckReadAccessSharing(gm *rbac.GroupMapping, email string, groups []string, shareUsers, shareGroups map[string]string, allowedRoles []string) error { + return gm.CheckAccessSharing(email, groups, shareUsers, shareGroups, allowedRoles, rbac.PermissionSecretsRead) +} + +// CheckWriteAccessSharing verifies write permission using sharing-aware access control. +func CheckWriteAccessSharing(gm *rbac.GroupMapping, email string, groups []string, shareUsers, shareGroups map[string]string, allowedRoles []string) error { + return gm.CheckAccessSharing(email, groups, shareUsers, shareGroups, allowedRoles, rbac.PermissionSecretsWrite) +} + +// CheckDeleteAccessSharing verifies delete permission using sharing-aware access control. +func CheckDeleteAccessSharing(gm *rbac.GroupMapping, email string, groups []string, shareUsers, shareGroups map[string]string, allowedRoles []string) error { + return gm.CheckAccessSharing(email, groups, shareUsers, shareGroups, allowedRoles, rbac.PermissionSecretsDelete) +} + +// CheckListAccessSharing verifies list permission using sharing-aware access control. +func CheckListAccessSharing(gm *rbac.GroupMapping, email string, groups []string, shareUsers, shareGroups map[string]string, allowedRoles []string) error { + return gm.CheckAccessSharing(email, groups, shareUsers, shareGroups, allowedRoles, rbac.PermissionSecretsList) +} + +// CheckAdminAccessSharing verifies admin permission using sharing-aware access control. +// Used for operations like updating sharing grants, which require owner-level access. +func CheckAdminAccessSharing(gm *rbac.GroupMapping, email string, groups []string, shareUsers, shareGroups map[string]string, allowedRoles []string) error { + return gm.CheckAccessSharing(email, groups, shareUsers, shareGroups, allowedRoles, rbac.PermissionSecretsAdmin) +} + // CheckAccess verifies that the user has at least one group in common with the allowed groups. // Deprecated: Use CheckReadAccess or CheckListAccess instead. // Returns nil if access is granted, or a PermissionDenied error otherwise. diff --git a/console/secrets/authz_test.go b/console/secrets/authz_test.go index b552d3c..2a625ee 100644 --- a/console/secrets/authz_test.go +++ b/console/secrets/authz_test.go @@ -160,6 +160,78 @@ func TestCheckListAccess(t *testing.T) { }) } +func TestCheckReadAccessSharing(t *testing.T) { + gm := defaultGM() + + t.Run("user email grant allows read", func(t *testing.T) { + err := CheckReadAccessSharing(gm, "alice@example.com", nil, + map[string]string{"alice@example.com": "viewer"}, nil, nil) + if err != nil { + t.Errorf("expected access granted, got: %v", err) + } + }) + + t.Run("group grant allows read", func(t *testing.T) { + err := CheckReadAccessSharing(gm, "bob@example.com", []string{"dev-team"}, + nil, map[string]string{"dev-team": "viewer"}, nil) + if err != nil { + t.Errorf("expected access granted, got: %v", err) + } + }) + + t.Run("legacy fallback works", func(t *testing.T) { + err := CheckReadAccessSharing(gm, "carol@example.com", []string{"viewer"}, + nil, nil, []string{"viewer"}) + if err != nil { + t.Errorf("expected access granted, got: %v", err) + } + }) +} + +func TestCheckWriteAccessSharing(t *testing.T) { + gm := defaultGM() + + t.Run("group grant allows write", func(t *testing.T) { + err := CheckWriteAccessSharing(gm, "bob@example.com", []string{"writers"}, + nil, map[string]string{"writers": "editor"}, nil) + if err != nil { + t.Errorf("expected access granted, got: %v", err) + } + }) + + t.Run("viewer grant denies write", func(t *testing.T) { + err := CheckWriteAccessSharing(gm, "alice@example.com", nil, + map[string]string{"alice@example.com": "viewer"}, nil, nil) + if err == nil { + t.Fatal("expected PermissionDenied, got nil") + } + }) +} + +func TestCheckDeleteAccessSharing(t *testing.T) { + gm := defaultGM() + + t.Run("owner email grant allows delete", func(t *testing.T) { + err := CheckDeleteAccessSharing(gm, "alice@example.com", nil, + map[string]string{"alice@example.com": "owner"}, nil, nil) + if err != nil { + t.Errorf("expected access granted, got: %v", err) + } + }) +} + +func TestCheckListAccessSharing(t *testing.T) { + gm := defaultGM() + + t.Run("user email grant allows list", func(t *testing.T) { + err := CheckListAccessSharing(gm, "alice@example.com", nil, + map[string]string{"alice@example.com": "viewer"}, nil, nil) + if err != nil { + t.Errorf("expected access granted, got: %v", err) + } + }) +} + func TestCheckAccess(t *testing.T) { t.Run("allows access when user has matching group", func(t *testing.T) { // Given: User groups ["developers", "readers"], allowed ["admin", "readers"] diff --git a/console/secrets/handler.go b/console/secrets/handler.go index 9e44ece..e842cd0 100644 --- a/console/secrets/handler.go +++ b/console/secrets/handler.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strings" "connectrpc.com/connect" corev1 "k8s.io/api/core/v1" @@ -53,7 +54,9 @@ func (h *Handler) ListSecrets( // Skip secrets with invalid annotations continue } - accessible := CheckListAccess(h.groupMapping, claims.Groups, allowedRoles) == nil + shareUsers, _ := GetShareUsers(&secret) + shareGroups, _ := GetShareGroups(&secret) + accessible := CheckListAccessSharing(h.groupMapping, claims.Email, claims.Groups, shareUsers, shareGroups, allowedRoles) == nil if accessible { accessibleCount++ } @@ -124,12 +127,14 @@ func (h *Handler) DeleteSecret( return nil, mapK8sError(err) } - // Check RBAC for delete access + // Check RBAC for delete access (sharing-aware) allowedRoles, err := GetAllowedRoles(secret) if err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, err) } - if err := CheckDeleteAccess(h.groupMapping, claims.Groups, allowedRoles); err != nil { + shareUsers, _ := GetShareUsers(secret) + shareGroups, _ := GetShareGroups(secret) + if err := CheckDeleteAccessSharing(h.groupMapping, claims.Email, claims.Groups, shareUsers, shareGroups, allowedRoles); err != nil { slog.WarnContext(ctx, "secret delete denied", slog.String("action", "secret_delete_denied"), slog.String("secret", req.Msg.Name), @@ -178,7 +183,7 @@ func (h *Handler) CreateSecret( // Check that the user has write permission based on their own roles. // Use the requested allowed_roles as the resource roles for the access check. - if err := CheckWriteAccess(h.groupMapping, claims.Groups, req.Msg.AllowedRoles); err != nil { + if err := CheckWriteAccessSharing(h.groupMapping, claims.Email, claims.Groups, nil, nil, req.Msg.AllowedRoles); err != nil { slog.WarnContext(ctx, "secret create denied", slog.String("action", "secret_create_denied"), slog.String("secret", req.Msg.Name), @@ -231,12 +236,14 @@ func (h *Handler) UpdateSecret( return nil, mapK8sError(err) } - // Check RBAC for write access + // Check RBAC for write access (sharing-aware) allowedRoles, err := GetAllowedRoles(secret) if err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, err) } - if err := CheckWriteAccess(h.groupMapping, claims.Groups, allowedRoles); err != nil { + shareUsers, _ := GetShareUsers(secret) + shareGroups, _ := GetShareGroups(secret) + if err := CheckWriteAccessSharing(h.groupMapping, claims.Email, claims.Groups, shareUsers, shareGroups, allowedRoles); err != nil { logAuditDenied(ctx, claims, secret.Name, allowedRoles) slog.WarnContext(ctx, "secret update denied", slog.String("action", "secret_update_denied"), @@ -262,14 +269,140 @@ func (h *Handler) UpdateSecret( return connect.NewResponse(&consolev1.UpdateSecretResponse{}), nil } +// UpdateSharing updates the sharing grants on a secret without touching its data. +// Requires ROLE_OWNER on the secret (via any grant source). +func (h *Handler) UpdateSharing( + ctx context.Context, + req *connect.Request[consolev1.UpdateSharingRequest], +) (*connect.Response[consolev1.UpdateSharingResponse], error) { + // Validate request + if req.Msg.Name == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("secret name is required")) + } + + // Get claims from context (set by AuthInterceptor) + claims := rpc.ClaimsFromContext(ctx) + if claims == nil { + return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("authentication required")) + } + + // Get existing secret to check RBAC + secret, err := h.k8s.GetSecret(ctx, req.Msg.Name) + if err != nil { + return nil, mapK8sError(err) + } + + // Check RBAC for admin access (owner only, sharing-aware) + allowedRoles, err := GetAllowedRoles(secret) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + shareUsers, _ := GetShareUsers(secret) + shareGroups, _ := GetShareGroups(secret) + if err := CheckAdminAccessSharing(h.groupMapping, claims.Email, claims.Groups, shareUsers, shareGroups, allowedRoles); err != nil { + slog.WarnContext(ctx, "sharing update denied", + slog.String("action", "sharing_update_denied"), + slog.String("secret", req.Msg.Name), + slog.String("sub", claims.Sub), + slog.String("email", claims.Email), + ) + return nil, err + } + + // Convert proto ShareGrant slices to annotation maps + newShareUsers := shareGrantsToMap(req.Msg.UserGrants) + newShareGroups := shareGrantsToMap(req.Msg.GroupGrants) + + // Persist the sharing annotations + updated, err := h.k8s.UpdateSharing(ctx, req.Msg.Name, newShareUsers, newShareGroups) + if err != nil { + return nil, mapK8sError(err) + } + + slog.InfoContext(ctx, "sharing updated", + slog.String("action", "sharing_update"), + slog.String("secret", req.Msg.Name), + slog.String("sub", claims.Sub), + slog.String("email", claims.Email), + ) + + // Build response metadata + metadata := buildSecretMetadata(updated, h.groupMapping, claims) + + return connect.NewResponse(&consolev1.UpdateSharingResponse{ + Metadata: metadata, + }), nil +} + +// shareGrantsToMap converts a slice of ShareGrant protos to a map[string]string +// suitable for storing as a Kubernetes annotation. +func shareGrantsToMap(grants []*consolev1.ShareGrant) map[string]string { + result := make(map[string]string, len(grants)) + for _, g := range grants { + if g.Principal != "" { + result[g.Principal] = strings.ToLower(g.Role.String()[len("ROLE_"):]) + } + } + return result +} + +// buildSecretMetadata creates SecretMetadata for a secret from the caller's perspective. +func buildSecretMetadata(secret *corev1.Secret, gm *rbac.GroupMapping, claims *rpc.Claims) *consolev1.SecretMetadata { + allowedRoles, _ := GetAllowedRoles(secret) + shareUsers, _ := GetShareUsers(secret) + shareGroups, _ := GetShareGroups(secret) + accessible := CheckListAccessSharing(gm, claims.Email, claims.Groups, shareUsers, shareGroups, allowedRoles) == nil + + // Build user grants + var userGrants []*consolev1.ShareGrant + for email, role := range shareUsers { + userGrants = append(userGrants, &consolev1.ShareGrant{ + Principal: email, + Role: protoRoleFromString(role), + }) + } + // Build group grants + var groupGrants []*consolev1.ShareGrant + for group, role := range shareGroups { + groupGrants = append(groupGrants, &consolev1.ShareGrant{ + Principal: group, + Role: protoRoleFromString(role), + }) + } + + return &consolev1.SecretMetadata{ + Name: secret.Name, + Accessible: accessible, + AllowedRoles: allowedRoles, + UserGrants: userGrants, + GroupGrants: groupGrants, + } +} + +// protoRoleFromString converts a role name string to the proto Role enum. +func protoRoleFromString(s string) consolev1.Role { + switch strings.ToLower(s) { + case "viewer": + return consolev1.Role_ROLE_VIEWER + case "editor": + return consolev1.Role_ROLE_EDITOR + case "owner": + return consolev1.Role_ROLE_OWNER + default: + return consolev1.Role_ROLE_UNSPECIFIED + } +} + // returnSecret checks RBAC and returns the secret data. func (h *Handler) returnSecret(ctx context.Context, claims *rpc.Claims, secret *corev1.Secret) (*connect.Response[consolev1.GetSecretResponse], error) { - // Check RBAC + // Check RBAC (sharing-aware) allowedRoles, err := GetAllowedRoles(secret) if err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, err) } - if err := CheckReadAccess(h.groupMapping, claims.Groups, allowedRoles); err != nil { + shareUsers, _ := GetShareUsers(secret) + shareGroups, _ := GetShareGroups(secret) + if err := CheckReadAccessSharing(h.groupMapping, claims.Email, claims.Groups, shareUsers, shareGroups, allowedRoles); err != nil { logAuditDenied(ctx, claims, secret.Name, allowedRoles) return nil, err } diff --git a/console/secrets/handler_test.go b/console/secrets/handler_test.go index 56a5bba..20daa1d 100644 --- a/console/secrets/handler_test.go +++ b/console/secrets/handler_test.go @@ -1540,3 +1540,317 @@ func TestHandler_ListSecrets(t *testing.T) { } }) } + +func TestHandler_UpdateSharing(t *testing.T) { + t.Run("owner can update sharing grants", func(t *testing.T) { + // Given: Secret with owner share-users grant for the caller + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + ManagedByLabel: ManagedByValue, + }, + Annotations: map[string]string{ + ShareUsersAnnotation: `{"alice@example.com":"owner"}`, + }, + }, + Data: map[string][]byte{"key": []byte("value")}, + } + fakeClient := fake.NewClientset(secret) + k8sClient := NewK8sClient(fakeClient, "test-namespace") + handler := NewHandler(k8sClient, rbac.NewGroupMapping(nil, nil, nil)) + + claims := &rpc.Claims{ + Sub: "user-123", + Email: "alice@example.com", + Groups: []string{}, + } + ctx := rpc.ContextWithClaims(context.Background(), claims) + + req := connect.NewRequest(&consolev1.UpdateSharingRequest{ + Name: "my-secret", + UserGrants: []*consolev1.ShareGrant{ + {Principal: "alice@example.com", Role: consolev1.Role_ROLE_OWNER}, + {Principal: "bob@example.com", Role: consolev1.Role_ROLE_VIEWER}, + }, + GroupGrants: []*consolev1.ShareGrant{ + {Principal: "dev-team", Role: consolev1.Role_ROLE_EDITOR}, + }, + }) + + // When: UpdateSharing RPC is called + resp, err := handler.UpdateSharing(ctx, req) + + // Then: Returns success with updated metadata + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if resp.Msg.Metadata == nil { + t.Fatal("expected metadata in response") + } + if resp.Msg.Metadata.Name != "my-secret" { + t.Errorf("expected name 'my-secret', got %q", resp.Msg.Metadata.Name) + } + + // Verify annotations were persisted + updated, err := k8sClient.GetSecret(ctx, "my-secret") + if err != nil { + t.Fatalf("failed to get updated secret: %v", err) + } + shareUsers, err := GetShareUsers(updated) + if err != nil { + t.Fatalf("failed to parse share-users: %v", err) + } + if shareUsers["alice@example.com"] != "owner" { + t.Errorf("expected alice=owner, got %q", shareUsers["alice@example.com"]) + } + if shareUsers["bob@example.com"] != "viewer" { + t.Errorf("expected bob=viewer, got %q", shareUsers["bob@example.com"]) + } + shareGroups, err := GetShareGroups(updated) + if err != nil { + t.Fatalf("failed to parse share-groups: %v", err) + } + if shareGroups["dev-team"] != "editor" { + t.Errorf("expected dev-team=editor, got %q", shareGroups["dev-team"]) + } + }) + + t.Run("non-owner gets PermissionDenied", func(t *testing.T) { + // Given: Secret where caller is only a viewer + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + ManagedByLabel: ManagedByValue, + }, + Annotations: map[string]string{ + ShareUsersAnnotation: `{"bob@example.com":"viewer"}`, + }, + }, + Data: map[string][]byte{"key": []byte("value")}, + } + fakeClient := fake.NewClientset(secret) + k8sClient := NewK8sClient(fakeClient, "test-namespace") + handler := NewHandler(k8sClient, rbac.NewGroupMapping(nil, nil, nil)) + + claims := &rpc.Claims{ + Sub: "user-456", + Email: "bob@example.com", + Groups: []string{}, + } + ctx := rpc.ContextWithClaims(context.Background(), claims) + + req := connect.NewRequest(&consolev1.UpdateSharingRequest{ + Name: "my-secret", + UserGrants: []*consolev1.ShareGrant{ + {Principal: "bob@example.com", Role: consolev1.Role_ROLE_OWNER}, + }, + }) + + // When: UpdateSharing RPC is called + _, err := handler.UpdateSharing(ctx, req) + + // Then: Returns PermissionDenied + if err == nil { + t.Fatal("expected PermissionDenied error, got nil") + } + connectErr, ok := err.(*connect.Error) + if !ok { + t.Fatalf("expected *connect.Error, got %T", err) + } + if connectErr.Code() != connect.CodePermissionDenied { + t.Errorf("expected CodePermissionDenied, got %v", connectErr.Code()) + } + }) + + t.Run("returns Unauthenticated for missing auth", func(t *testing.T) { + fakeClient := fake.NewClientset() + k8sClient := NewK8sClient(fakeClient, "test-namespace") + handler := NewHandler(k8sClient, rbac.NewGroupMapping(nil, nil, nil)) + + ctx := context.Background() + req := connect.NewRequest(&consolev1.UpdateSharingRequest{ + Name: "my-secret", + }) + + _, err := handler.UpdateSharing(ctx, req) + + if err == nil { + t.Fatal("expected error, got nil") + } + connectErr, ok := err.(*connect.Error) + if !ok { + t.Fatalf("expected *connect.Error, got %T", err) + } + if connectErr.Code() != connect.CodeUnauthenticated { + t.Errorf("expected CodeUnauthenticated, got %v", connectErr.Code()) + } + }) + + t.Run("returns InvalidArgument for empty name", func(t *testing.T) { + fakeClient := fake.NewClientset() + k8sClient := NewK8sClient(fakeClient, "test-namespace") + handler := NewHandler(k8sClient, rbac.NewGroupMapping(nil, nil, nil)) + + claims := &rpc.Claims{ + Sub: "user-123", + Email: "alice@example.com", + Groups: []string{"owner"}, + } + ctx := rpc.ContextWithClaims(context.Background(), claims) + + req := connect.NewRequest(&consolev1.UpdateSharingRequest{ + Name: "", + }) + + _, err := handler.UpdateSharing(ctx, req) + + if err == nil { + t.Fatal("expected error, got nil") + } + connectErr, ok := err.(*connect.Error) + if !ok { + t.Fatalf("expected *connect.Error, got %T", err) + } + if connectErr.Code() != connect.CodeInvalidArgument { + t.Errorf("expected CodeInvalidArgument, got %v", connectErr.Code()) + } + }) + + t.Run("returns NotFound for non-existent secret", func(t *testing.T) { + fakeClient := fake.NewClientset() + k8sClient := NewK8sClient(fakeClient, "test-namespace") + handler := NewHandler(k8sClient, rbac.NewGroupMapping(nil, nil, nil)) + + claims := &rpc.Claims{ + Sub: "user-123", + Email: "alice@example.com", + Groups: []string{"owner"}, + } + ctx := rpc.ContextWithClaims(context.Background(), claims) + + req := connect.NewRequest(&consolev1.UpdateSharingRequest{ + Name: "missing-secret", + }) + + _, err := handler.UpdateSharing(ctx, req) + + if err == nil { + t.Fatal("expected error, got nil") + } + connectErr, ok := err.(*connect.Error) + if !ok { + t.Fatalf("expected *connect.Error, got %T", err) + } + if connectErr.Code() != connect.CodeNotFound { + t.Errorf("expected CodeNotFound, got %v", connectErr.Code()) + } + }) +} + +func TestHandler_UpdateSharing_AuditLogging(t *testing.T) { + t.Run("logs sharing_update on success", func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + ManagedByLabel: ManagedByValue, + }, + Annotations: map[string]string{ + ShareUsersAnnotation: `{"alice@example.com":"owner"}`, + }, + }, + Data: map[string][]byte{"key": []byte("value")}, + } + fakeClient := fake.NewClientset(secret) + k8sClient := NewK8sClient(fakeClient, "test-namespace") + handler := NewHandler(k8sClient, rbac.NewGroupMapping(nil, nil, nil)) + + logHandler := &testLogHandler{} + oldLogger := slog.Default() + slog.SetDefault(slog.New(logHandler)) + defer slog.SetDefault(oldLogger) + + claims := &rpc.Claims{ + Sub: "user-123", + Email: "alice@example.com", + Groups: []string{}, + } + ctx := rpc.ContextWithClaims(context.Background(), claims) + + req := connect.NewRequest(&consolev1.UpdateSharingRequest{ + Name: "my-secret", + UserGrants: []*consolev1.ShareGrant{ + {Principal: "alice@example.com", Role: consolev1.Role_ROLE_OWNER}, + }, + }) + + _, err := handler.UpdateSharing(ctx, req) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + record := logHandler.findRecord("sharing_update") + if record == nil { + t.Fatal("expected log record with action='sharing_update', got none") + } + if record.Level != slog.LevelInfo { + t.Errorf("expected Info level, got %v", record.Level) + } + }) + + t.Run("logs sharing_update_denied on RBAC failure", func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-secret", + Namespace: "test-namespace", + Labels: map[string]string{ + ManagedByLabel: ManagedByValue, + }, + Annotations: map[string]string{ + ShareUsersAnnotation: `{"bob@example.com":"viewer"}`, + }, + }, + Data: map[string][]byte{"key": []byte("value")}, + } + fakeClient := fake.NewClientset(secret) + k8sClient := NewK8sClient(fakeClient, "test-namespace") + handler := NewHandler(k8sClient, rbac.NewGroupMapping(nil, nil, nil)) + + logHandler := &testLogHandler{} + oldLogger := slog.Default() + slog.SetDefault(slog.New(logHandler)) + defer slog.SetDefault(oldLogger) + + claims := &rpc.Claims{ + Sub: "user-456", + Email: "bob@example.com", + Groups: []string{}, + } + ctx := rpc.ContextWithClaims(context.Background(), claims) + + req := connect.NewRequest(&consolev1.UpdateSharingRequest{ + Name: "my-secret", + UserGrants: []*consolev1.ShareGrant{ + {Principal: "bob@example.com", Role: consolev1.Role_ROLE_OWNER}, + }, + }) + + _, err := handler.UpdateSharing(ctx, req) + if err == nil { + t.Fatal("expected error, got nil") + } + + record := logHandler.findRecord("sharing_update_denied") + if record == nil { + t.Fatal("expected log record with action='sharing_update_denied', got none") + } + if record.Level != slog.LevelWarn { + t.Errorf("expected Warn level, got %v", record.Level) + } + }) +} diff --git a/console/secrets/k8s.go b/console/secrets/k8s.go index ac0afea..79c3c7e 100644 --- a/console/secrets/k8s.go +++ b/console/secrets/k8s.go @@ -18,6 +18,14 @@ const AllowedRolesAnnotation = "holos.run/allowed-roles" // Deprecated: Use AllowedRolesAnnotation instead. const AllowedGroupsAnnotation = "holos.run/allowed-groups" +// ShareUsersAnnotation is the annotation key for per-user sharing grants. +// Value is a JSON object mapping email address → role name. +const ShareUsersAnnotation = "holos.run/share-users" + +// ShareGroupsAnnotation is the annotation key for per-group sharing grants. +// Value is a JSON object mapping OIDC group name → role name. +const ShareGroupsAnnotation = "holos.run/share-groups" + // ManagedByLabel is the label key used to identify secrets managed by the console. const ManagedByLabel = "app.kubernetes.io/managed-by" @@ -117,6 +125,36 @@ func (c *K8sClient) DeleteSecret(ctx context.Context, name string) error { return c.client.CoreV1().Secrets(c.namespace).Delete(ctx, name, metav1.DeleteOptions{}) } +// UpdateSharing updates the sharing annotations on an existing secret. +// Returns FailedPrecondition if the secret does not have the console managed-by label. +func (c *K8sClient) UpdateSharing(ctx context.Context, name string, shareUsers, shareGroups map[string]string) (*corev1.Secret, error) { + slog.DebugContext(ctx, "updating sharing on kubernetes secret", + slog.String("namespace", c.namespace), + slog.String("name", name), + ) + secret, err := c.GetSecret(ctx, name) + if err != nil { + return nil, err + } + if secret.Labels == nil || secret.Labels[ManagedByLabel] != ManagedByValue { + return nil, fmt.Errorf("secret %q is not managed by %s", name, ManagedByValue) + } + if secret.Annotations == nil { + secret.Annotations = make(map[string]string) + } + usersJSON, err := json.Marshal(shareUsers) + if err != nil { + return nil, fmt.Errorf("marshaling share-users: %w", err) + } + groupsJSON, err := json.Marshal(shareGroups) + if err != nil { + return nil, fmt.Errorf("marshaling share-groups: %w", err) + } + secret.Annotations[ShareUsersAnnotation] = string(usersJSON) + secret.Annotations[ShareGroupsAnnotation] = string(groupsJSON) + return c.client.CoreV1().Secrets(c.namespace).Update(ctx, secret, metav1.UpdateOptions{}) +} + // GetAllowedRoles parses the holos.run/allowed-roles annotation from a secret. // Falls back to holos.run/allowed-groups if the new annotation is not present. // Returns an empty slice if both annotations are missing. @@ -147,6 +185,42 @@ func GetAllowedRoles(secret *corev1.Secret) ([]string, error) { return []string{}, nil } +// GetShareUsers parses the holos.run/share-users annotation from a secret. +// Returns an empty map if the annotation is missing. +// Returns an error if the annotation contains invalid JSON. +func GetShareUsers(secret *corev1.Secret) (map[string]string, error) { + if secret.Annotations == nil { + return map[string]string{}, nil + } + value, ok := secret.Annotations[ShareUsersAnnotation] + if !ok { + return map[string]string{}, nil + } + var users map[string]string + if err := json.Unmarshal([]byte(value), &users); err != nil { + return nil, fmt.Errorf("invalid %s annotation: %w", ShareUsersAnnotation, err) + } + return users, nil +} + +// GetShareGroups parses the holos.run/share-groups annotation from a secret. +// Returns an empty map if the annotation is missing. +// Returns an error if the annotation contains invalid JSON. +func GetShareGroups(secret *corev1.Secret) (map[string]string, error) { + if secret.Annotations == nil { + return map[string]string{}, nil + } + value, ok := secret.Annotations[ShareGroupsAnnotation] + if !ok { + return map[string]string{}, nil + } + var groups map[string]string + if err := json.Unmarshal([]byte(value), &groups); err != nil { + return nil, fmt.Errorf("invalid %s annotation: %w", ShareGroupsAnnotation, err) + } + return groups, nil +} + // GetAllowedGroups parses the holos.run/allowed-groups annotation from a secret. // Deprecated: Use GetAllowedRoles instead, which supports backward compatibility. // Returns an empty slice if the annotation is missing. diff --git a/console/secrets/k8s_test.go b/console/secrets/k8s_test.go index 533ca7c..5185ae9 100644 --- a/console/secrets/k8s_test.go +++ b/console/secrets/k8s_test.go @@ -371,3 +371,134 @@ func TestGetAllowedGroups(t *testing.T) { } }) } + +func TestGetShareUsers(t *testing.T) { + t.Run("parses share-users annotation", func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + ShareUsersAnnotation: `{"alice@example.com":"editor","bob@example.com":"viewer"}`, + }, + }, + } + users, err := GetShareUsers(secret) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(users) != 2 { + t.Fatalf("expected 2 users, got %d", len(users)) + } + if users["alice@example.com"] != "editor" { + t.Errorf("expected alice=editor, got %s", users["alice@example.com"]) + } + if users["bob@example.com"] != "viewer" { + t.Errorf("expected bob=viewer, got %s", users["bob@example.com"]) + } + }) + + t.Run("missing annotation returns empty map", func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + } + users, err := GetShareUsers(secret) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(users) != 0 { + t.Errorf("expected empty map, got %v", users) + } + }) + + t.Run("nil annotations returns empty map", func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{}, + } + users, err := GetShareUsers(secret) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(users) != 0 { + t.Errorf("expected empty map, got %v", users) + } + }) + + t.Run("invalid JSON returns error", func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + ShareUsersAnnotation: `{invalid`, + }, + }, + } + _, err := GetShareUsers(secret) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } + }) +} + +func TestGetShareGroups(t *testing.T) { + t.Run("parses share-groups annotation", func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + ShareGroupsAnnotation: `{"platform-team":"owner","dev-team":"viewer"}`, + }, + }, + } + groups, err := GetShareGroups(secret) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(groups) != 2 { + t.Fatalf("expected 2 groups, got %d", len(groups)) + } + if groups["platform-team"] != "owner" { + t.Errorf("expected platform-team=owner, got %s", groups["platform-team"]) + } + }) + + t.Run("missing annotation returns empty map", func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + } + groups, err := GetShareGroups(secret) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(groups) != 0 { + t.Errorf("expected empty map, got %v", groups) + } + }) + + t.Run("nil annotations returns empty map", func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{}, + } + groups, err := GetShareGroups(secret) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(groups) != 0 { + t.Errorf("expected empty map, got %v", groups) + } + }) + + t.Run("invalid JSON returns error", func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + ShareGroupsAnnotation: `not-json`, + }, + }, + } + _, err := GetShareGroups(secret) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } + }) +} diff --git a/console/ui/index.html b/console/ui/index.html index 4c4d9ec..30015a6 100644 --- a/console/ui/index.html +++ b/console/ui/index.html @@ -4,7 +4,7 @@ Holos Console - +
diff --git a/gen/holos/console/v1/consolev1connect/secrets.connect.go b/gen/holos/console/v1/consolev1connect/secrets.connect.go index 54d53ab..32284d9 100644 --- a/gen/holos/console/v1/consolev1connect/secrets.connect.go +++ b/gen/holos/console/v1/consolev1connect/secrets.connect.go @@ -48,6 +48,9 @@ const ( // SecretsServiceDeleteSecretProcedure is the fully-qualified name of the SecretsService's // DeleteSecret RPC. SecretsServiceDeleteSecretProcedure = "/holos.console.v1.SecretsService/DeleteSecret" + // SecretsServiceUpdateSharingProcedure is the fully-qualified name of the SecretsService's + // UpdateSharing RPC. + SecretsServiceUpdateSharingProcedure = "/holos.console.v1.SecretsService/UpdateSharing" ) // SecretsServiceClient is a client for the holos.console.v1.SecretsService service. @@ -72,6 +75,9 @@ type SecretsServiceClient interface { // Requires authentication and PERMISSION_SECRETS_DELETE. // Only operates on secrets with the console managed-by label. DeleteSecret(context.Context, *connect.Request[v1.DeleteSecretRequest]) (*connect.Response[v1.DeleteSecretResponse], error) + // UpdateSharing updates the sharing grants on a secret without touching its data. + // Requires ROLE_OWNER on the secret. + UpdateSharing(context.Context, *connect.Request[v1.UpdateSharingRequest]) (*connect.Response[v1.UpdateSharingResponse], error) } // NewSecretsServiceClient constructs a client for the holos.console.v1.SecretsService service. By @@ -115,16 +121,23 @@ func NewSecretsServiceClient(httpClient connect.HTTPClient, baseURL string, opts connect.WithSchema(secretsServiceMethods.ByName("DeleteSecret")), connect.WithClientOptions(opts...), ), + updateSharing: connect.NewClient[v1.UpdateSharingRequest, v1.UpdateSharingResponse]( + httpClient, + baseURL+SecretsServiceUpdateSharingProcedure, + connect.WithSchema(secretsServiceMethods.ByName("UpdateSharing")), + connect.WithClientOptions(opts...), + ), } } // secretsServiceClient implements SecretsServiceClient. type secretsServiceClient struct { - listSecrets *connect.Client[v1.ListSecretsRequest, v1.ListSecretsResponse] - getSecret *connect.Client[v1.GetSecretRequest, v1.GetSecretResponse] - updateSecret *connect.Client[v1.UpdateSecretRequest, v1.UpdateSecretResponse] - createSecret *connect.Client[v1.CreateSecretRequest, v1.CreateSecretResponse] - deleteSecret *connect.Client[v1.DeleteSecretRequest, v1.DeleteSecretResponse] + listSecrets *connect.Client[v1.ListSecretsRequest, v1.ListSecretsResponse] + getSecret *connect.Client[v1.GetSecretRequest, v1.GetSecretResponse] + updateSecret *connect.Client[v1.UpdateSecretRequest, v1.UpdateSecretResponse] + createSecret *connect.Client[v1.CreateSecretRequest, v1.CreateSecretResponse] + deleteSecret *connect.Client[v1.DeleteSecretRequest, v1.DeleteSecretResponse] + updateSharing *connect.Client[v1.UpdateSharingRequest, v1.UpdateSharingResponse] } // ListSecrets calls holos.console.v1.SecretsService.ListSecrets. @@ -152,6 +165,11 @@ func (c *secretsServiceClient) DeleteSecret(ctx context.Context, req *connect.Re return c.deleteSecret.CallUnary(ctx, req) } +// UpdateSharing calls holos.console.v1.SecretsService.UpdateSharing. +func (c *secretsServiceClient) UpdateSharing(ctx context.Context, req *connect.Request[v1.UpdateSharingRequest]) (*connect.Response[v1.UpdateSharingResponse], error) { + return c.updateSharing.CallUnary(ctx, req) +} + // SecretsServiceHandler is an implementation of the holos.console.v1.SecretsService service. type SecretsServiceHandler interface { // ListSecrets returns all secrets in the current namespace with console label. @@ -174,6 +192,9 @@ type SecretsServiceHandler interface { // Requires authentication and PERMISSION_SECRETS_DELETE. // Only operates on secrets with the console managed-by label. DeleteSecret(context.Context, *connect.Request[v1.DeleteSecretRequest]) (*connect.Response[v1.DeleteSecretResponse], error) + // UpdateSharing updates the sharing grants on a secret without touching its data. + // Requires ROLE_OWNER on the secret. + UpdateSharing(context.Context, *connect.Request[v1.UpdateSharingRequest]) (*connect.Response[v1.UpdateSharingResponse], error) } // NewSecretsServiceHandler builds an HTTP handler from the service implementation. It returns the @@ -213,6 +234,12 @@ func NewSecretsServiceHandler(svc SecretsServiceHandler, opts ...connect.Handler connect.WithSchema(secretsServiceMethods.ByName("DeleteSecret")), connect.WithHandlerOptions(opts...), ) + secretsServiceUpdateSharingHandler := connect.NewUnaryHandler( + SecretsServiceUpdateSharingProcedure, + svc.UpdateSharing, + connect.WithSchema(secretsServiceMethods.ByName("UpdateSharing")), + connect.WithHandlerOptions(opts...), + ) return "/holos.console.v1.SecretsService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case SecretsServiceListSecretsProcedure: @@ -225,6 +252,8 @@ func NewSecretsServiceHandler(svc SecretsServiceHandler, opts ...connect.Handler secretsServiceCreateSecretHandler.ServeHTTP(w, r) case SecretsServiceDeleteSecretProcedure: secretsServiceDeleteSecretHandler.ServeHTTP(w, r) + case SecretsServiceUpdateSharingProcedure: + secretsServiceUpdateSharingHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -253,3 +282,7 @@ func (UnimplementedSecretsServiceHandler) CreateSecret(context.Context, *connect func (UnimplementedSecretsServiceHandler) DeleteSecret(context.Context, *connect.Request[v1.DeleteSecretRequest]) (*connect.Response[v1.DeleteSecretResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("holos.console.v1.SecretsService.DeleteSecret is not implemented")) } + +func (UnimplementedSecretsServiceHandler) UpdateSharing(context.Context, *connect.Request[v1.UpdateSharingRequest]) (*connect.Response[v1.UpdateSharingResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("holos.console.v1.SecretsService.UpdateSharing is not implemented")) +} diff --git a/gen/holos/console/v1/secrets.pb.go b/gen/holos/console/v1/secrets.pb.go index 18ba95b..781b227 100644 --- a/gen/holos/console/v1/secrets.pb.go +++ b/gen/holos/console/v1/secrets.pb.go @@ -498,7 +498,11 @@ type SecretMetadata struct { AllowedGroups []string `protobuf:"bytes,3,rep,name=allowed_groups,json=allowedGroups,proto3" json:"allowed_groups,omitempty"` // allowed_roles contains the roles that have permission to read this secret. // Populated regardless of whether the user has access, to show in the UI. - AllowedRoles []string `protobuf:"bytes,4,rep,name=allowed_roles,json=allowedRoles,proto3" json:"allowed_roles,omitempty"` + AllowedRoles []string `protobuf:"bytes,4,rep,name=allowed_roles,json=allowedRoles,proto3" json:"allowed_roles,omitempty"` + // user_grants contains per-user sharing grants on this secret. + UserGrants []*ShareGrant `protobuf:"bytes,5,rep,name=user_grants,json=userGrants,proto3" json:"user_grants,omitempty"` + // group_grants contains per-group sharing grants on this secret. + GroupGrants []*ShareGrant `protobuf:"bytes,6,rep,name=group_grants,json=groupGrants,proto3" json:"group_grants,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -562,11 +566,190 @@ func (x *SecretMetadata) GetAllowedRoles() []string { return nil } +func (x *SecretMetadata) GetUserGrants() []*ShareGrant { + if x != nil { + return x.UserGrants + } + return nil +} + +func (x *SecretMetadata) GetGroupGrants() []*ShareGrant { + if x != nil { + return x.GroupGrants + } + return nil +} + +// ShareGrant represents a sharing grant for a principal (user email or group name). +type ShareGrant struct { + state protoimpl.MessageState `protogen:"open.v1"` + // principal is the email address (for users) or group name (for groups). + Principal string `protobuf:"bytes,1,opt,name=principal,proto3" json:"principal,omitempty"` + // role is the permission level granted to the principal. + Role Role `protobuf:"varint,2,opt,name=role,proto3,enum=holos.console.v1.Role" json:"role,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ShareGrant) Reset() { + *x = ShareGrant{} + mi := &file_holos_console_v1_secrets_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ShareGrant) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ShareGrant) ProtoMessage() {} + +func (x *ShareGrant) ProtoReflect() protoreflect.Message { + mi := &file_holos_console_v1_secrets_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 ShareGrant.ProtoReflect.Descriptor instead. +func (*ShareGrant) Descriptor() ([]byte, []int) { + return file_holos_console_v1_secrets_proto_rawDescGZIP(), []int{11} +} + +func (x *ShareGrant) GetPrincipal() string { + if x != nil { + return x.Principal + } + return "" +} + +func (x *ShareGrant) GetRole() Role { + if x != nil { + return x.Role + } + return Role_ROLE_UNSPECIFIED +} + +// UpdateSharingRequest contains the sharing grants to set on a secret. +type UpdateSharingRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // name is the name of the secret to update sharing for. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // user_grants are the per-user sharing grants to set. + UserGrants []*ShareGrant `protobuf:"bytes,2,rep,name=user_grants,json=userGrants,proto3" json:"user_grants,omitempty"` + // group_grants are the per-group sharing grants to set. + GroupGrants []*ShareGrant `protobuf:"bytes,3,rep,name=group_grants,json=groupGrants,proto3" json:"group_grants,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateSharingRequest) Reset() { + *x = UpdateSharingRequest{} + mi := &file_holos_console_v1_secrets_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateSharingRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateSharingRequest) ProtoMessage() {} + +func (x *UpdateSharingRequest) ProtoReflect() protoreflect.Message { + mi := &file_holos_console_v1_secrets_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 UpdateSharingRequest.ProtoReflect.Descriptor instead. +func (*UpdateSharingRequest) Descriptor() ([]byte, []int) { + return file_holos_console_v1_secrets_proto_rawDescGZIP(), []int{12} +} + +func (x *UpdateSharingRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *UpdateSharingRequest) GetUserGrants() []*ShareGrant { + if x != nil { + return x.UserGrants + } + return nil +} + +func (x *UpdateSharingRequest) GetGroupGrants() []*ShareGrant { + if x != nil { + return x.GroupGrants + } + return nil +} + +// UpdateSharingResponse contains the updated secret metadata. +type UpdateSharingResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // metadata contains the updated secret metadata including new sharing grants. + Metadata *SecretMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateSharingResponse) Reset() { + *x = UpdateSharingResponse{} + mi := &file_holos_console_v1_secrets_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateSharingResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateSharingResponse) ProtoMessage() {} + +func (x *UpdateSharingResponse) ProtoReflect() protoreflect.Message { + mi := &file_holos_console_v1_secrets_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 UpdateSharingResponse.ProtoReflect.Descriptor instead. +func (*UpdateSharingResponse) Descriptor() ([]byte, []int) { + return file_holos_console_v1_secrets_proto_rawDescGZIP(), []int{13} +} + +func (x *UpdateSharingResponse) GetMetadata() *SecretMetadata { + if x != nil { + return x.Metadata + } + return nil +} + var File_holos_console_v1_secrets_proto protoreflect.FileDescriptor const file_holos_console_v1_secrets_proto_rawDesc = "" + "\n" + - "\x1eholos/console/v1/secrets.proto\x12\x10holos.console.v1\"&\n" + + "\x1eholos/console/v1/secrets.proto\x12\x10holos.console.v1\x1a\x1bholos/console/v1/rbac.proto\"&\n" + "\x10GetSecretRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\"\x8f\x01\n" + "\x11GetSecretResponse\x12A\n" + @@ -595,20 +778,35 @@ const file_holos_console_v1_secrets_proto_rawDesc = "" + "\x04name\x18\x01 \x01(\tR\x04name\")\n" + "\x13DeleteSecretRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\"\x16\n" + - "\x14DeleteSecretResponse\"\x94\x01\n" + + "\x14DeleteSecretResponse\"\x94\x02\n" + "\x0eSecretMetadata\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1e\n" + "\n" + "accessible\x18\x02 \x01(\bR\n" + "accessible\x12)\n" + "\x0eallowed_groups\x18\x03 \x03(\tB\x02\x18\x01R\rallowedGroups\x12#\n" + - "\rallowed_roles\x18\x04 \x03(\tR\fallowedRoles2\xdf\x03\n" + + "\rallowed_roles\x18\x04 \x03(\tR\fallowedRoles\x12=\n" + + "\vuser_grants\x18\x05 \x03(\v2\x1c.holos.console.v1.ShareGrantR\n" + + "userGrants\x12?\n" + + "\fgroup_grants\x18\x06 \x03(\v2\x1c.holos.console.v1.ShareGrantR\vgroupGrants\"V\n" + + "\n" + + "ShareGrant\x12\x1c\n" + + "\tprincipal\x18\x01 \x01(\tR\tprincipal\x12*\n" + + "\x04role\x18\x02 \x01(\x0e2\x16.holos.console.v1.RoleR\x04role\"\xaa\x01\n" + + "\x14UpdateSharingRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12=\n" + + "\vuser_grants\x18\x02 \x03(\v2\x1c.holos.console.v1.ShareGrantR\n" + + "userGrants\x12?\n" + + "\fgroup_grants\x18\x03 \x03(\v2\x1c.holos.console.v1.ShareGrantR\vgroupGrants\"U\n" + + "\x15UpdateSharingResponse\x12<\n" + + "\bmetadata\x18\x01 \x01(\v2 .holos.console.v1.SecretMetadataR\bmetadata2\xc1\x04\n" + "\x0eSecretsService\x12Z\n" + "\vListSecrets\x12$.holos.console.v1.ListSecretsRequest\x1a%.holos.console.v1.ListSecretsResponse\x12T\n" + "\tGetSecret\x12\".holos.console.v1.GetSecretRequest\x1a#.holos.console.v1.GetSecretResponse\x12]\n" + "\fUpdateSecret\x12%.holos.console.v1.UpdateSecretRequest\x1a&.holos.console.v1.UpdateSecretResponse\x12]\n" + "\fCreateSecret\x12%.holos.console.v1.CreateSecretRequest\x1a&.holos.console.v1.CreateSecretResponse\x12]\n" + - "\fDeleteSecret\x12%.holos.console.v1.DeleteSecretRequest\x1a&.holos.console.v1.DeleteSecretResponseBCZAgithub.com/holos-run/holos-console/gen/holos/console/v1;consolev1b\x06proto3" + "\fDeleteSecret\x12%.holos.console.v1.DeleteSecretRequest\x1a&.holos.console.v1.DeleteSecretResponse\x12`\n" + + "\rUpdateSharing\x12&.holos.console.v1.UpdateSharingRequest\x1a'.holos.console.v1.UpdateSharingResponseBCZAgithub.com/holos-run/holos-console/gen/holos/console/v1;consolev1b\x06proto3" var ( file_holos_console_v1_secrets_proto_rawDescOnce sync.Once @@ -622,43 +820,55 @@ func file_holos_console_v1_secrets_proto_rawDescGZIP() []byte { return file_holos_console_v1_secrets_proto_rawDescData } -var file_holos_console_v1_secrets_proto_msgTypes = make([]protoimpl.MessageInfo, 14) +var file_holos_console_v1_secrets_proto_msgTypes = make([]protoimpl.MessageInfo, 17) var file_holos_console_v1_secrets_proto_goTypes = []any{ - (*GetSecretRequest)(nil), // 0: holos.console.v1.GetSecretRequest - (*GetSecretResponse)(nil), // 1: holos.console.v1.GetSecretResponse - (*ListSecretsRequest)(nil), // 2: holos.console.v1.ListSecretsRequest - (*ListSecretsResponse)(nil), // 3: holos.console.v1.ListSecretsResponse - (*UpdateSecretRequest)(nil), // 4: holos.console.v1.UpdateSecretRequest - (*UpdateSecretResponse)(nil), // 5: holos.console.v1.UpdateSecretResponse - (*CreateSecretRequest)(nil), // 6: holos.console.v1.CreateSecretRequest - (*CreateSecretResponse)(nil), // 7: holos.console.v1.CreateSecretResponse - (*DeleteSecretRequest)(nil), // 8: holos.console.v1.DeleteSecretRequest - (*DeleteSecretResponse)(nil), // 9: holos.console.v1.DeleteSecretResponse - (*SecretMetadata)(nil), // 10: holos.console.v1.SecretMetadata - nil, // 11: holos.console.v1.GetSecretResponse.DataEntry - nil, // 12: holos.console.v1.UpdateSecretRequest.DataEntry - nil, // 13: holos.console.v1.CreateSecretRequest.DataEntry + (*GetSecretRequest)(nil), // 0: holos.console.v1.GetSecretRequest + (*GetSecretResponse)(nil), // 1: holos.console.v1.GetSecretResponse + (*ListSecretsRequest)(nil), // 2: holos.console.v1.ListSecretsRequest + (*ListSecretsResponse)(nil), // 3: holos.console.v1.ListSecretsResponse + (*UpdateSecretRequest)(nil), // 4: holos.console.v1.UpdateSecretRequest + (*UpdateSecretResponse)(nil), // 5: holos.console.v1.UpdateSecretResponse + (*CreateSecretRequest)(nil), // 6: holos.console.v1.CreateSecretRequest + (*CreateSecretResponse)(nil), // 7: holos.console.v1.CreateSecretResponse + (*DeleteSecretRequest)(nil), // 8: holos.console.v1.DeleteSecretRequest + (*DeleteSecretResponse)(nil), // 9: holos.console.v1.DeleteSecretResponse + (*SecretMetadata)(nil), // 10: holos.console.v1.SecretMetadata + (*ShareGrant)(nil), // 11: holos.console.v1.ShareGrant + (*UpdateSharingRequest)(nil), // 12: holos.console.v1.UpdateSharingRequest + (*UpdateSharingResponse)(nil), // 13: holos.console.v1.UpdateSharingResponse + nil, // 14: holos.console.v1.GetSecretResponse.DataEntry + nil, // 15: holos.console.v1.UpdateSecretRequest.DataEntry + nil, // 16: holos.console.v1.CreateSecretRequest.DataEntry + (Role)(0), // 17: holos.console.v1.Role } var file_holos_console_v1_secrets_proto_depIdxs = []int32{ - 11, // 0: holos.console.v1.GetSecretResponse.data:type_name -> holos.console.v1.GetSecretResponse.DataEntry + 14, // 0: holos.console.v1.GetSecretResponse.data:type_name -> holos.console.v1.GetSecretResponse.DataEntry 10, // 1: holos.console.v1.ListSecretsResponse.secrets:type_name -> holos.console.v1.SecretMetadata - 12, // 2: holos.console.v1.UpdateSecretRequest.data:type_name -> holos.console.v1.UpdateSecretRequest.DataEntry - 13, // 3: holos.console.v1.CreateSecretRequest.data:type_name -> holos.console.v1.CreateSecretRequest.DataEntry - 2, // 4: holos.console.v1.SecretsService.ListSecrets:input_type -> holos.console.v1.ListSecretsRequest - 0, // 5: holos.console.v1.SecretsService.GetSecret:input_type -> holos.console.v1.GetSecretRequest - 4, // 6: holos.console.v1.SecretsService.UpdateSecret:input_type -> holos.console.v1.UpdateSecretRequest - 6, // 7: holos.console.v1.SecretsService.CreateSecret:input_type -> holos.console.v1.CreateSecretRequest - 8, // 8: holos.console.v1.SecretsService.DeleteSecret:input_type -> holos.console.v1.DeleteSecretRequest - 3, // 9: holos.console.v1.SecretsService.ListSecrets:output_type -> holos.console.v1.ListSecretsResponse - 1, // 10: holos.console.v1.SecretsService.GetSecret:output_type -> holos.console.v1.GetSecretResponse - 5, // 11: holos.console.v1.SecretsService.UpdateSecret:output_type -> holos.console.v1.UpdateSecretResponse - 7, // 12: holos.console.v1.SecretsService.CreateSecret:output_type -> holos.console.v1.CreateSecretResponse - 9, // 13: holos.console.v1.SecretsService.DeleteSecret:output_type -> holos.console.v1.DeleteSecretResponse - 9, // [9:14] is the sub-list for method output_type - 4, // [4:9] 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 + 15, // 2: holos.console.v1.UpdateSecretRequest.data:type_name -> holos.console.v1.UpdateSecretRequest.DataEntry + 16, // 3: holos.console.v1.CreateSecretRequest.data:type_name -> holos.console.v1.CreateSecretRequest.DataEntry + 11, // 4: holos.console.v1.SecretMetadata.user_grants:type_name -> holos.console.v1.ShareGrant + 11, // 5: holos.console.v1.SecretMetadata.group_grants:type_name -> holos.console.v1.ShareGrant + 17, // 6: holos.console.v1.ShareGrant.role:type_name -> holos.console.v1.Role + 11, // 7: holos.console.v1.UpdateSharingRequest.user_grants:type_name -> holos.console.v1.ShareGrant + 11, // 8: holos.console.v1.UpdateSharingRequest.group_grants:type_name -> holos.console.v1.ShareGrant + 10, // 9: holos.console.v1.UpdateSharingResponse.metadata:type_name -> holos.console.v1.SecretMetadata + 2, // 10: holos.console.v1.SecretsService.ListSecrets:input_type -> holos.console.v1.ListSecretsRequest + 0, // 11: holos.console.v1.SecretsService.GetSecret:input_type -> holos.console.v1.GetSecretRequest + 4, // 12: holos.console.v1.SecretsService.UpdateSecret:input_type -> holos.console.v1.UpdateSecretRequest + 6, // 13: holos.console.v1.SecretsService.CreateSecret:input_type -> holos.console.v1.CreateSecretRequest + 8, // 14: holos.console.v1.SecretsService.DeleteSecret:input_type -> holos.console.v1.DeleteSecretRequest + 12, // 15: holos.console.v1.SecretsService.UpdateSharing:input_type -> holos.console.v1.UpdateSharingRequest + 3, // 16: holos.console.v1.SecretsService.ListSecrets:output_type -> holos.console.v1.ListSecretsResponse + 1, // 17: holos.console.v1.SecretsService.GetSecret:output_type -> holos.console.v1.GetSecretResponse + 5, // 18: holos.console.v1.SecretsService.UpdateSecret:output_type -> holos.console.v1.UpdateSecretResponse + 7, // 19: holos.console.v1.SecretsService.CreateSecret:output_type -> holos.console.v1.CreateSecretResponse + 9, // 20: holos.console.v1.SecretsService.DeleteSecret:output_type -> holos.console.v1.DeleteSecretResponse + 13, // 21: holos.console.v1.SecretsService.UpdateSharing:output_type -> holos.console.v1.UpdateSharingResponse + 16, // [16:22] is the sub-list for method output_type + 10, // [10:16] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_holos_console_v1_secrets_proto_init() } @@ -666,13 +876,14 @@ func file_holos_console_v1_secrets_proto_init() { if File_holos_console_v1_secrets_proto != nil { return } + file_holos_console_v1_rbac_proto_init() type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_holos_console_v1_secrets_proto_rawDesc), len(file_holos_console_v1_secrets_proto_rawDesc)), NumEnums: 0, - NumMessages: 14, + NumMessages: 17, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/holos/console/v1/secrets.proto b/proto/holos/console/v1/secrets.proto index d726033..5ab719b 100644 --- a/proto/holos/console/v1/secrets.proto +++ b/proto/holos/console/v1/secrets.proto @@ -2,6 +2,8 @@ syntax = "proto3"; package holos.console.v1; +import "holos/console/v1/rbac.proto"; + option go_package = "github.com/holos-run/holos-console/gen/holos/console/v1;consolev1"; // SecretsService provides access to Kubernetes secrets with RBAC. @@ -30,6 +32,10 @@ service SecretsService { // Requires authentication and PERMISSION_SECRETS_DELETE. // Only operates on secrets with the console managed-by label. rpc DeleteSecret(DeleteSecretRequest) returns (DeleteSecretResponse); + + // UpdateSharing updates the sharing grants on a secret without touching its data. + // Requires ROLE_OWNER on the secret. + rpc UpdateSharing(UpdateSharingRequest) returns (UpdateSharingResponse); } // GetSecretRequest contains the name of the secret to retrieve. @@ -106,4 +112,32 @@ message SecretMetadata { // allowed_roles contains the roles that have permission to read this secret. // Populated regardless of whether the user has access, to show in the UI. repeated string allowed_roles = 4; + // user_grants contains per-user sharing grants on this secret. + repeated ShareGrant user_grants = 5; + // group_grants contains per-group sharing grants on this secret. + repeated ShareGrant group_grants = 6; +} + +// ShareGrant represents a sharing grant for a principal (user email or group name). +message ShareGrant { + // principal is the email address (for users) or group name (for groups). + string principal = 1; + // role is the permission level granted to the principal. + Role role = 2; +} + +// UpdateSharingRequest contains the sharing grants to set on a secret. +message UpdateSharingRequest { + // name is the name of the secret to update sharing for. + string name = 1; + // user_grants are the per-user sharing grants to set. + repeated ShareGrant user_grants = 2; + // group_grants are the per-group sharing grants to set. + repeated ShareGrant group_grants = 3; +} + +// UpdateSharingResponse contains the updated secret metadata. +message UpdateSharingResponse { + // metadata contains the updated secret metadata including new sharing grants. + SecretMetadata metadata = 1; } diff --git a/ui/src/gen/holos/console/v1/secrets_connect.d.ts b/ui/src/gen/holos/console/v1/secrets_connect.d.ts index 289b20b..8c3ed53 100644 --- a/ui/src/gen/holos/console/v1/secrets_connect.d.ts +++ b/ui/src/gen/holos/console/v1/secrets_connect.d.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { CreateSecretRequest, CreateSecretResponse, DeleteSecretRequest, DeleteSecretResponse, GetSecretRequest, GetSecretResponse, ListSecretsRequest, ListSecretsResponse, UpdateSecretRequest, UpdateSecretResponse } from "./secrets_pb.js"; +import { CreateSecretRequest, CreateSecretResponse, DeleteSecretRequest, DeleteSecretResponse, GetSecretRequest, GetSecretResponse, ListSecretsRequest, ListSecretsResponse, UpdateSecretRequest, UpdateSecretResponse, UpdateSharingRequest, UpdateSharingResponse } from "./secrets_pb.js"; import { MethodKind } from "@bufbuild/protobuf"; /** @@ -79,6 +79,18 @@ export declare const SecretsService: { readonly O: typeof DeleteSecretResponse, readonly kind: MethodKind.Unary, }, + /** + * UpdateSharing updates the sharing grants on a secret without touching its data. + * Requires ROLE_OWNER on the secret. + * + * @generated from rpc holos.console.v1.SecretsService.UpdateSharing + */ + readonly updateSharing: { + readonly name: "UpdateSharing", + readonly I: typeof UpdateSharingRequest, + readonly O: typeof UpdateSharingResponse, + readonly kind: MethodKind.Unary, + }, } }; diff --git a/ui/src/gen/holos/console/v1/secrets_connect.js b/ui/src/gen/holos/console/v1/secrets_connect.js index 39eba6b..6e734f1 100644 --- a/ui/src/gen/holos/console/v1/secrets_connect.js +++ b/ui/src/gen/holos/console/v1/secrets_connect.js @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { CreateSecretRequest, CreateSecretResponse, DeleteSecretRequest, DeleteSecretResponse, GetSecretRequest, GetSecretResponse, ListSecretsRequest, ListSecretsResponse, UpdateSecretRequest, UpdateSecretResponse } from "./secrets_pb.js"; +import { CreateSecretRequest, CreateSecretResponse, DeleteSecretRequest, DeleteSecretResponse, GetSecretRequest, GetSecretResponse, ListSecretsRequest, ListSecretsResponse, UpdateSecretRequest, UpdateSecretResponse, UpdateSharingRequest, UpdateSharingResponse } from "./secrets_pb.js"; import { MethodKind } from "@bufbuild/protobuf"; /** @@ -79,6 +79,18 @@ export const SecretsService = { O: DeleteSecretResponse, kind: MethodKind.Unary, }, + /** + * UpdateSharing updates the sharing grants on a secret without touching its data. + * Requires ROLE_OWNER on the secret. + * + * @generated from rpc holos.console.v1.SecretsService.UpdateSharing + */ + updateSharing: { + name: "UpdateSharing", + I: UpdateSharingRequest, + O: UpdateSharingResponse, + kind: MethodKind.Unary, + }, } }; diff --git a/ui/src/gen/holos/console/v1/secrets_pb.d.ts b/ui/src/gen/holos/console/v1/secrets_pb.d.ts index 9b6d7d7..cf7b74c 100644 --- a/ui/src/gen/holos/console/v1/secrets_pb.d.ts +++ b/ui/src/gen/holos/console/v1/secrets_pb.d.ts @@ -4,6 +4,7 @@ import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; import type { Message } from "@bufbuild/protobuf"; +import type { Role } from "./rbac_pb"; /** * Describes the file holos/console/v1/secrets.proto. @@ -254,6 +255,20 @@ export declare type SecretMetadata = Message<"holos.console.v1.SecretMetadata"> * @generated from field: repeated string allowed_roles = 4; */ allowedRoles: string[]; + + /** + * user_grants contains per-user sharing grants on this secret. + * + * @generated from field: repeated holos.console.v1.ShareGrant user_grants = 5; + */ + userGrants: ShareGrant[]; + + /** + * group_grants contains per-group sharing grants on this secret. + * + * @generated from field: repeated holos.console.v1.ShareGrant group_grants = 6; + */ + groupGrants: ShareGrant[]; }; /** @@ -262,6 +277,87 @@ export declare type SecretMetadata = Message<"holos.console.v1.SecretMetadata"> */ export declare const SecretMetadataSchema: GenMessage; +/** + * ShareGrant represents a sharing grant for a principal (user email or group name). + * + * @generated from message holos.console.v1.ShareGrant + */ +export declare type ShareGrant = Message<"holos.console.v1.ShareGrant"> & { + /** + * principal is the email address (for users) or group name (for groups). + * + * @generated from field: string principal = 1; + */ + principal: string; + + /** + * role is the permission level granted to the principal. + * + * @generated from field: holos.console.v1.Role role = 2; + */ + role: Role; +}; + +/** + * Describes the message holos.console.v1.ShareGrant. + * Use `create(ShareGrantSchema)` to create a new message. + */ +export declare const ShareGrantSchema: GenMessage; + +/** + * UpdateSharingRequest contains the sharing grants to set on a secret. + * + * @generated from message holos.console.v1.UpdateSharingRequest + */ +export declare type UpdateSharingRequest = Message<"holos.console.v1.UpdateSharingRequest"> & { + /** + * name is the name of the secret to update sharing for. + * + * @generated from field: string name = 1; + */ + name: string; + + /** + * user_grants are the per-user sharing grants to set. + * + * @generated from field: repeated holos.console.v1.ShareGrant user_grants = 2; + */ + userGrants: ShareGrant[]; + + /** + * group_grants are the per-group sharing grants to set. + * + * @generated from field: repeated holos.console.v1.ShareGrant group_grants = 3; + */ + groupGrants: ShareGrant[]; +}; + +/** + * Describes the message holos.console.v1.UpdateSharingRequest. + * Use `create(UpdateSharingRequestSchema)` to create a new message. + */ +export declare const UpdateSharingRequestSchema: GenMessage; + +/** + * UpdateSharingResponse contains the updated secret metadata. + * + * @generated from message holos.console.v1.UpdateSharingResponse + */ +export declare type UpdateSharingResponse = Message<"holos.console.v1.UpdateSharingResponse"> & { + /** + * metadata contains the updated secret metadata including new sharing grants. + * + * @generated from field: holos.console.v1.SecretMetadata metadata = 1; + */ + metadata?: SecretMetadata; +}; + +/** + * Describes the message holos.console.v1.UpdateSharingResponse. + * Use `create(UpdateSharingResponseSchema)` to create a new message. + */ +export declare const UpdateSharingResponseSchema: GenMessage; + /** * SecretsService provides access to Kubernetes secrets with RBAC. * @@ -328,5 +424,16 @@ export declare const SecretsService: GenService<{ input: typeof DeleteSecretRequestSchema; output: typeof DeleteSecretResponseSchema; }, + /** + * UpdateSharing updates the sharing grants on a secret without touching its data. + * Requires ROLE_OWNER on the secret. + * + * @generated from rpc holos.console.v1.SecretsService.UpdateSharing + */ + updateSharing: { + methodKind: "unary"; + input: typeof UpdateSharingRequestSchema; + output: typeof UpdateSharingResponseSchema; + }, }>; diff --git a/ui/src/gen/holos/console/v1/secrets_pb.js b/ui/src/gen/holos/console/v1/secrets_pb.js index 1fe1b5c..5c2a62e 100644 --- a/ui/src/gen/holos/console/v1/secrets_pb.js +++ b/ui/src/gen/holos/console/v1/secrets_pb.js @@ -3,12 +3,13 @@ /* eslint-disable */ import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_holos_console_v1_rbac } from "./rbac_pb"; /** * Describes the file holos/console/v1/secrets.proto. */ export const file_holos_console_v1_secrets = /*@__PURE__*/ - fileDesc("Ch5ob2xvcy9jb25zb2xlL3YxL3NlY3JldHMucHJvdG8SEGhvbG9zLmNvbnNvbGUudjEiIAoQR2V0U2VjcmV0UmVxdWVzdBIMCgRuYW1lGAEgASgJIn0KEUdldFNlY3JldFJlc3BvbnNlEjsKBGRhdGEYASADKAsyLS5ob2xvcy5jb25zb2xlLnYxLkdldFNlY3JldFJlc3BvbnNlLkRhdGFFbnRyeRorCglEYXRhRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgMOgI4ASIUChJMaXN0U2VjcmV0c1JlcXVlc3QiSAoTTGlzdFNlY3JldHNSZXNwb25zZRIxCgdzZWNyZXRzGAEgAygLMiAuaG9sb3MuY29uc29sZS52MS5TZWNyZXRNZXRhZGF0YSKPAQoTVXBkYXRlU2VjcmV0UmVxdWVzdBIMCgRuYW1lGAEgASgJEj0KBGRhdGEYAiADKAsyLy5ob2xvcy5jb25zb2xlLnYxLlVwZGF0ZVNlY3JldFJlcXVlc3QuRGF0YUVudHJ5GisKCURhdGFFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAw6AjgBIhYKFFVwZGF0ZVNlY3JldFJlc3BvbnNlIqYBChNDcmVhdGVTZWNyZXRSZXF1ZXN0EgwKBG5hbWUYASABKAkSPQoEZGF0YRgCIAMoCzIvLmhvbG9zLmNvbnNvbGUudjEuQ3JlYXRlU2VjcmV0UmVxdWVzdC5EYXRhRW50cnkSFQoNYWxsb3dlZF9yb2xlcxgDIAMoCRorCglEYXRhRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgMOgI4ASIkChRDcmVhdGVTZWNyZXRSZXNwb25zZRIMCgRuYW1lGAEgASgJIiMKE0RlbGV0ZVNlY3JldFJlcXVlc3QSDAoEbmFtZRgBIAEoCSIWChREZWxldGVTZWNyZXRSZXNwb25zZSJlCg5TZWNyZXRNZXRhZGF0YRIMCgRuYW1lGAEgASgJEhIKCmFjY2Vzc2libGUYAiABKAgSGgoOYWxsb3dlZF9ncm91cHMYAyADKAlCAhgBEhUKDWFsbG93ZWRfcm9sZXMYBCADKAky3wMKDlNlY3JldHNTZXJ2aWNlEloKC0xpc3RTZWNyZXRzEiQuaG9sb3MuY29uc29sZS52MS5MaXN0U2VjcmV0c1JlcXVlc3QaJS5ob2xvcy5jb25zb2xlLnYxLkxpc3RTZWNyZXRzUmVzcG9uc2USVAoJR2V0U2VjcmV0EiIuaG9sb3MuY29uc29sZS52MS5HZXRTZWNyZXRSZXF1ZXN0GiMuaG9sb3MuY29uc29sZS52MS5HZXRTZWNyZXRSZXNwb25zZRJdCgxVcGRhdGVTZWNyZXQSJS5ob2xvcy5jb25zb2xlLnYxLlVwZGF0ZVNlY3JldFJlcXVlc3QaJi5ob2xvcy5jb25zb2xlLnYxLlVwZGF0ZVNlY3JldFJlc3BvbnNlEl0KDENyZWF0ZVNlY3JldBIlLmhvbG9zLmNvbnNvbGUudjEuQ3JlYXRlU2VjcmV0UmVxdWVzdBomLmhvbG9zLmNvbnNvbGUudjEuQ3JlYXRlU2VjcmV0UmVzcG9uc2USXQoMRGVsZXRlU2VjcmV0EiUuaG9sb3MuY29uc29sZS52MS5EZWxldGVTZWNyZXRSZXF1ZXN0GiYuaG9sb3MuY29uc29sZS52MS5EZWxldGVTZWNyZXRSZXNwb25zZUJDWkFnaXRodWIuY29tL2hvbG9zLXJ1bi9ob2xvcy1jb25zb2xlL2dlbi9ob2xvcy9jb25zb2xlL3YxO2NvbnNvbGV2MWIGcHJvdG8z"); + fileDesc("Ch5ob2xvcy9jb25zb2xlL3YxL3NlY3JldHMucHJvdG8SEGhvbG9zLmNvbnNvbGUudjEiIAoQR2V0U2VjcmV0UmVxdWVzdBIMCgRuYW1lGAEgASgJIn0KEUdldFNlY3JldFJlc3BvbnNlEjsKBGRhdGEYASADKAsyLS5ob2xvcy5jb25zb2xlLnYxLkdldFNlY3JldFJlc3BvbnNlLkRhdGFFbnRyeRorCglEYXRhRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgMOgI4ASIUChJMaXN0U2VjcmV0c1JlcXVlc3QiSAoTTGlzdFNlY3JldHNSZXNwb25zZRIxCgdzZWNyZXRzGAEgAygLMiAuaG9sb3MuY29uc29sZS52MS5TZWNyZXRNZXRhZGF0YSKPAQoTVXBkYXRlU2VjcmV0UmVxdWVzdBIMCgRuYW1lGAEgASgJEj0KBGRhdGEYAiADKAsyLy5ob2xvcy5jb25zb2xlLnYxLlVwZGF0ZVNlY3JldFJlcXVlc3QuRGF0YUVudHJ5GisKCURhdGFFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAw6AjgBIhYKFFVwZGF0ZVNlY3JldFJlc3BvbnNlIqYBChNDcmVhdGVTZWNyZXRSZXF1ZXN0EgwKBG5hbWUYASABKAkSPQoEZGF0YRgCIAMoCzIvLmhvbG9zLmNvbnNvbGUudjEuQ3JlYXRlU2VjcmV0UmVxdWVzdC5EYXRhRW50cnkSFQoNYWxsb3dlZF9yb2xlcxgDIAMoCRorCglEYXRhRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgMOgI4ASIkChRDcmVhdGVTZWNyZXRSZXNwb25zZRIMCgRuYW1lGAEgASgJIiMKE0RlbGV0ZVNlY3JldFJlcXVlc3QSDAoEbmFtZRgBIAEoCSIWChREZWxldGVTZWNyZXRSZXNwb25zZSLMAQoOU2VjcmV0TWV0YWRhdGESDAoEbmFtZRgBIAEoCRISCgphY2Nlc3NpYmxlGAIgASgIEhoKDmFsbG93ZWRfZ3JvdXBzGAMgAygJQgIYARIVCg1hbGxvd2VkX3JvbGVzGAQgAygJEjEKC3VzZXJfZ3JhbnRzGAUgAygLMhwuaG9sb3MuY29uc29sZS52MS5TaGFyZUdyYW50EjIKDGdyb3VwX2dyYW50cxgGIAMoCzIcLmhvbG9zLmNvbnNvbGUudjEuU2hhcmVHcmFudCJFCgpTaGFyZUdyYW50EhEKCXByaW5jaXBhbBgBIAEoCRIkCgRyb2xlGAIgASgOMhYuaG9sb3MuY29uc29sZS52MS5Sb2xlIosBChRVcGRhdGVTaGFyaW5nUmVxdWVzdBIMCgRuYW1lGAEgASgJEjEKC3VzZXJfZ3JhbnRzGAIgAygLMhwuaG9sb3MuY29uc29sZS52MS5TaGFyZUdyYW50EjIKDGdyb3VwX2dyYW50cxgDIAMoCzIcLmhvbG9zLmNvbnNvbGUudjEuU2hhcmVHcmFudCJLChVVcGRhdGVTaGFyaW5nUmVzcG9uc2USMgoIbWV0YWRhdGEYASABKAsyIC5ob2xvcy5jb25zb2xlLnYxLlNlY3JldE1ldGFkYXRhMsEECg5TZWNyZXRzU2VydmljZRJaCgtMaXN0U2VjcmV0cxIkLmhvbG9zLmNvbnNvbGUudjEuTGlzdFNlY3JldHNSZXF1ZXN0GiUuaG9sb3MuY29uc29sZS52MS5MaXN0U2VjcmV0c1Jlc3BvbnNlElQKCUdldFNlY3JldBIiLmhvbG9zLmNvbnNvbGUudjEuR2V0U2VjcmV0UmVxdWVzdBojLmhvbG9zLmNvbnNvbGUudjEuR2V0U2VjcmV0UmVzcG9uc2USXQoMVXBkYXRlU2VjcmV0EiUuaG9sb3MuY29uc29sZS52MS5VcGRhdGVTZWNyZXRSZXF1ZXN0GiYuaG9sb3MuY29uc29sZS52MS5VcGRhdGVTZWNyZXRSZXNwb25zZRJdCgxDcmVhdGVTZWNyZXQSJS5ob2xvcy5jb25zb2xlLnYxLkNyZWF0ZVNlY3JldFJlcXVlc3QaJi5ob2xvcy5jb25zb2xlLnYxLkNyZWF0ZVNlY3JldFJlc3BvbnNlEl0KDERlbGV0ZVNlY3JldBIlLmhvbG9zLmNvbnNvbGUudjEuRGVsZXRlU2VjcmV0UmVxdWVzdBomLmhvbG9zLmNvbnNvbGUudjEuRGVsZXRlU2VjcmV0UmVzcG9uc2USYAoNVXBkYXRlU2hhcmluZxImLmhvbG9zLmNvbnNvbGUudjEuVXBkYXRlU2hhcmluZ1JlcXVlc3QaJy5ob2xvcy5jb25zb2xlLnYxLlVwZGF0ZVNoYXJpbmdSZXNwb25zZUJDWkFnaXRodWIuY29tL2hvbG9zLXJ1bi9ob2xvcy1jb25zb2xlL2dlbi9ob2xvcy9jb25zb2xlL3YxO2NvbnNvbGV2MWIGcHJvdG8z", [file_holos_console_v1_rbac]); /** * Describes the message holos.console.v1.GetSecretRequest. @@ -87,6 +88,27 @@ export const DeleteSecretResponseSchema = /*@__PURE__*/ export const SecretMetadataSchema = /*@__PURE__*/ messageDesc(file_holos_console_v1_secrets, 10); +/** + * Describes the message holos.console.v1.ShareGrant. + * Use `create(ShareGrantSchema)` to create a new message. + */ +export const ShareGrantSchema = /*@__PURE__*/ + messageDesc(file_holos_console_v1_secrets, 11); + +/** + * Describes the message holos.console.v1.UpdateSharingRequest. + * Use `create(UpdateSharingRequestSchema)` to create a new message. + */ +export const UpdateSharingRequestSchema = /*@__PURE__*/ + messageDesc(file_holos_console_v1_secrets, 12); + +/** + * Describes the message holos.console.v1.UpdateSharingResponse. + * Use `create(UpdateSharingResponseSchema)` to create a new message. + */ +export const UpdateSharingResponseSchema = /*@__PURE__*/ + messageDesc(file_holos_console_v1_secrets, 13); + /** * SecretsService provides access to Kubernetes secrets with RBAC. *