Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"

"github.com/holos-run/holos-console/console"
"github.com/holos-run/holos-console/console/rbac"
)

var (
Expand All @@ -26,6 +27,9 @@ var (
refreshTokenTTL string
namespace string
logLevel string
viewerGroups string
editorGroups string
ownerGroups string
)

// Command returns the root cobra command for the CLI.
Expand Down Expand Up @@ -79,6 +83,11 @@ func Command() *cobra.Command {
// Kubernetes flags
cmd.Flags().StringVar(&namespace, "namespace", "holos-console", "Kubernetes namespace for secrets")

// RBAC group mapping flags
cmd.Flags().StringVar(&viewerGroups, "viewer-groups", "", "Comma-separated OIDC groups that map to the viewer role (default: viewer)")
cmd.Flags().StringVar(&editorGroups, "editor-groups", "", "Comma-separated OIDC groups that map to the editor role (default: editor)")
cmd.Flags().StringVar(&ownerGroups, "owner-groups", "", "Comma-separated OIDC groups that map to the owner role (default: owner)")

// Logging flags
cmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "Log level (debug, info, warn, error)")

Expand Down Expand Up @@ -194,6 +203,9 @@ func Run(cmd *cobra.Command, args []string) error {
IDTokenTTL: idTTL,
RefreshTokenTTL: refreshTTL,
Namespace: namespace,
ViewerGroups: rbac.ParseGroups(viewerGroups),
EditorGroups: rbac.ParseGroups(editorGroups),
OwnerGroups: rbac.ParseGroups(ownerGroups),
}

server := console.New(cfg)
Expand Down
35 changes: 35 additions & 0 deletions cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,41 @@ func TestDeriveIssuer(t *testing.T) {
}
}

func TestGroupFlags(t *testing.T) {
t.Run("viewer-groups flag is registered", func(t *testing.T) {
cmd := Command()
f := cmd.Flags().Lookup("viewer-groups")
if f == nil {
t.Fatal("expected --viewer-groups flag to be registered")
}
if f.DefValue != "" {
t.Errorf("expected empty default, got %q", f.DefValue)
}
})

t.Run("editor-groups flag is registered", func(t *testing.T) {
cmd := Command()
f := cmd.Flags().Lookup("editor-groups")
if f == nil {
t.Fatal("expected --editor-groups flag to be registered")
}
if f.DefValue != "" {
t.Errorf("expected empty default, got %q", f.DefValue)
}
})

t.Run("owner-groups flag is registered", func(t *testing.T) {
cmd := Command()
f := cmd.Flags().Lookup("owner-groups")
if f == nil {
t.Fatal("expected --owner-groups flag to be registered")
}
if f.DefValue != "" {
t.Errorf("expected empty default, got %q", f.DefValue)
}
})
}

func TestTTLParsing(t *testing.T) {
tests := []struct {
name string
Expand Down
18 changes: 17 additions & 1 deletion console/console.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"golang.org/x/net/http2/h2c"

"github.com/holos-run/holos-console/console/oidc"
"github.com/holos-run/holos-console/console/rbac"
"github.com/holos-run/holos-console/console/rpc"
"github.com/holos-run/holos-console/console/secrets"
"github.com/holos-run/holos-console/gen/holos/console/v1/consolev1connect"
Expand Down Expand Up @@ -77,6 +78,18 @@ type Config struct {
// Namespace is the Kubernetes namespace for secrets.
// Default: "holos-console"
Namespace string

// ViewerGroups are the OIDC groups that map to the viewer role.
// When nil, defaults to ["viewer"].
ViewerGroups []string

// EditorGroups are the OIDC groups that map to the editor role.
// When nil, defaults to ["editor"].
EditorGroups []string

// OwnerGroups are the OIDC groups that map to the owner role.
// When nil, defaults to ["owner"].
OwnerGroups []string
}

// OIDCConfig is the OIDC configuration injected into the frontend.
Expand Down Expand Up @@ -186,8 +199,11 @@ func (s *Server) Serve(ctx context.Context) error {
slog.Info("no kubernetes config available, using dummy-secret only")
}

// Create RBAC group mapping from configuration
groupMapping := rbac.NewGroupMapping(s.cfg.ViewerGroups, s.cfg.EditorGroups, s.cfg.OwnerGroups)

// Register SecretsService (protected - requires auth)
secretsHandler := secrets.NewHandler(secretsK8s)
secretsHandler := secrets.NewHandler(secretsK8s, groupMapping)
secretsPath, secretsHTTPHandler := consolev1connect.NewSecretsServiceHandler(secretsHandler, protectedInterceptors)
mux.Handle(secretsPath, secretsHTTPHandler)

Expand Down
132 changes: 119 additions & 13 deletions console/rbac/rbac.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,33 +63,139 @@ func HasPermission(role Role, permission Permission) bool {
return perms[permission]
}

// MapGroupToRole maps a group name to a Role using case-insensitive matching.
// Returns RoleUnspecified for unknown groups.
func MapGroupToRole(group string) Role {
switch strings.ToLower(group) {
case "viewer":
return RoleViewer
case "editor":
return RoleEditor
case "owner":
return RoleOwner
default:
// GroupMapping holds the mapping from OIDC group names to roles.
// When custom groups are provided for a role, only those groups map to that role.
// When no custom groups are provided (nil), the default group name is used.
type GroupMapping struct {
groupToRole map[string]Role
}

// NewGroupMapping creates a GroupMapping. For each role, if the provided slice is
// non-nil, those group names are used; otherwise the default group name
// ("viewer", "editor", or "owner") is used.
func NewGroupMapping(viewerGroups, editorGroups, ownerGroups []string) *GroupMapping {
if viewerGroups == nil {
viewerGroups = []string{"viewer"}
}
if editorGroups == nil {
editorGroups = []string{"editor"}
}
if ownerGroups == nil {
ownerGroups = []string{"owner"}
}

m := make(map[string]Role)
for _, g := range viewerGroups {
m[strings.ToLower(g)] = RoleViewer
}
for _, g := range editorGroups {
m[strings.ToLower(g)] = RoleEditor
}
for _, g := range ownerGroups {
m[strings.ToLower(g)] = RoleOwner
}

return &GroupMapping{groupToRole: m}
}

// ParseGroups splits a comma-separated string into a slice of trimmed group names.
// Returns nil for an empty string.
func ParseGroups(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
groups := make([]string, 0, len(parts))
for _, p := range parts {
g := strings.TrimSpace(p)
if g != "" {
groups = append(groups, g)
}
}
if len(groups) == 0 {
return nil
}
return groups
}

// MapGroupToRole maps a group name to a Role using the configured mapping.
func (gm *GroupMapping) MapGroupToRole(group string) Role {
role, ok := gm.groupToRole[strings.ToLower(group)]
if !ok {
return RoleUnspecified
}
return role
}

// MapGroupsToRoles maps a slice of group names to roles, filtering out unknown groups.
func MapGroupsToRoles(groups []string) []Role {
func (gm *GroupMapping) MapGroupsToRoles(groups []string) []Role {
roles := make([]Role, 0, len(groups))
for _, g := range groups {
role := MapGroupToRole(g)
role := gm.MapGroupToRole(g)
if role != RoleUnspecified {
roles = append(roles, role)
}
}
return roles
}

// CheckAccess verifies that the user has at least one role that grants the required permission.
// This is the method form that uses the configured group mapping.
func (gm *GroupMapping) CheckAccess(userGroups, allowedRoles []string, permission Permission) error {
// Map user groups to roles
userRoles := gm.MapGroupsToRoles(userGroups)

// Find the minimum required role level from allowed roles
minLevel := -1
for _, r := range allowedRoles {
role := gm.MapGroupToRole(r)
if role != RoleUnspecified {
level := roleLevel[role]
if minLevel < 0 || level < minLevel {
minLevel = level
}
}
}

// If no valid allowed roles, deny access
if minLevel < 0 {
return connect.NewError(
connect.CodePermissionDenied,
fmt.Errorf("RBAC: authorization denied (allowed roles: [%s])",
strings.Join(allowedRoles, " ")),
)
}

// Check if any user role is at or above the minimum level AND has the required permission
for _, userRole := range userRoles {
if roleLevel[userRole] >= minLevel && HasPermission(userRole, permission) {
return nil
}
}

return connect.NewError(
connect.CodePermissionDenied,
fmt.Errorf("RBAC: authorization denied (allowed roles: [%s])",
strings.Join(allowedRoles, " ")),
)
}

// defaultMapping is the package-level default GroupMapping using built-in group names.
var defaultMapping = NewGroupMapping(nil, nil, nil)

// MapGroupToRole maps a group name to a Role using case-insensitive matching.
// Returns RoleUnspecified for unknown groups.
// Uses the default group mapping (viewer, editor, owner).
func MapGroupToRole(group string) Role {
return defaultMapping.MapGroupToRole(group)
}

// MapGroupsToRoles maps a slice of group names to roles, filtering out unknown groups.
// Uses the default group mapping.
func MapGroupsToRoles(groups []string) []Role {
return defaultMapping.MapGroupsToRoles(groups)
}

// roleLevel defines the hierarchy level of each role for comparison.
// Higher values indicate more privileged roles.
var roleLevel = map[Role]int{
Expand Down
Loading