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.
*