mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-27 15:37:03 +00:00
feat(oidc): add fine grained access control (#91)
* feat(access-control): fine grain tokens * fix(docs): clean wording * test(access-control): add tests for team extraction from access tokens and bucket info permissions * test(vocabulary): add test for ExpandGlob function to reject non-glob patterns * feat(helm): add multi-user access control documentation and schema support
This commit is contained in:
@@ -30,10 +30,12 @@ type Service struct {
|
||||
|
||||
// UserInfo represents authenticated user information
|
||||
type UserInfo struct {
|
||||
Username string
|
||||
Email string
|
||||
Name string
|
||||
Roles []string
|
||||
Username string
|
||||
Email string
|
||||
Name string
|
||||
Roles []string
|
||||
Teams []string // raw team claim values (team_attribute_path), OIDC only
|
||||
AuthMethod string // "oidc" | "admin" | "token"; "" on legacy sessions
|
||||
}
|
||||
|
||||
// NewAuthService creates a new authentication service
|
||||
@@ -179,6 +181,10 @@ func (a *Service) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserIn
|
||||
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
|
||||
}
|
||||
|
||||
if a.authConfig.OIDC.TeamAttributePath != "" {
|
||||
userInfo.Teams = extractRoles(claims, a.authConfig.OIDC.TeamAttributePath)
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
@@ -219,6 +225,10 @@ func (a *Service) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserIn
|
||||
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
|
||||
}
|
||||
|
||||
if a.authConfig.OIDC.TeamAttributePath != "" {
|
||||
userInfo.Teams = extractRoles(claims, a.authConfig.OIDC.TeamAttributePath)
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
@@ -252,6 +262,33 @@ func (a *Service) ExtractRolesFromAccessToken(accessToken string) []string {
|
||||
return extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
|
||||
}
|
||||
|
||||
// ExtractTeamsFromAccessToken parses the access token JWT payload and extracts
|
||||
// team claim values using the configured team_attribute_path. Same rationale
|
||||
// as ExtractRolesFromAccessToken: Keycloak-style IdPs often emit group claims
|
||||
// only in the access token, which came from a verified code exchange.
|
||||
func (a *Service) ExtractTeamsFromAccessToken(accessToken string) []string {
|
||||
if accessToken == "" || a.authConfig.OIDC.TeamAttributePath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(accessToken, ".")
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return extractRoles(claims, a.authConfig.OIDC.TeamAttributePath)
|
||||
}
|
||||
|
||||
// IsAdmin checks if the user has any of the configured admin roles.
|
||||
func (a *Service) IsAdmin(userInfo *UserInfo) bool {
|
||||
adminRoles := a.authConfig.OIDC.EffectiveAdminRoles()
|
||||
@@ -388,9 +425,11 @@ func (a *Service) ValidateSessionToken(tokenString string) (*UserInfo, error) {
|
||||
}
|
||||
|
||||
return &UserInfo{
|
||||
Username: claims.Username,
|
||||
Email: claims.Email,
|
||||
Name: claims.Name,
|
||||
Roles: claims.Roles,
|
||||
Username: claims.Username,
|
||||
Email: claims.Email,
|
||||
Name: claims.Name,
|
||||
Roles: claims.Roles,
|
||||
Teams: claims.Teams,
|
||||
AuthMethod: claims.AuthMethod,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
)
|
||||
|
||||
func TestSessionTokenRoundTripsTeamsAndMethod(t *testing.T) {
|
||||
svc, err := NewAuthService(&config.AuthConfig{}, &config.ServerConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := &UserInfo{
|
||||
Username: "alice",
|
||||
Email: "alice@example.com",
|
||||
Teams: []string{"garage-team-backend", "garage-team-data"},
|
||||
AuthMethod: "oidc",
|
||||
}
|
||||
token, err := svc.GenerateSessionToken(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := svc.ValidateSessionToken(token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Teams) != 2 || out.Teams[0] != "garage-team-backend" {
|
||||
t.Errorf("Teams = %v, want round-trip", out.Teams)
|
||||
}
|
||||
if out.AuthMethod != "oidc" {
|
||||
t.Errorf("AuthMethod = %q, want oidc", out.AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTeamsFromAccessToken(t *testing.T) {
|
||||
svc, err := NewAuthService(&config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{TeamAttributePath: "groups"},
|
||||
}, &config.ServerConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Unsigned JWT with {"groups":["team-a","team-b"]} payload. Extraction
|
||||
// parses claims without verifying (token came from a verified exchange).
|
||||
// header {"alg":"none"} / payload base64url of {"groups":["team-a","team-b"]}
|
||||
tok := "eyJhbGciOiJub25lIn0.eyJncm91cHMiOlsidGVhbS1hIiwidGVhbS1iIl19.x"
|
||||
got := svc.ExtractTeamsFromAccessToken(tok)
|
||||
if len(got) != 2 || got[0] != "team-a" || got[1] != "team-b" {
|
||||
t.Errorf("ExtractTeamsFromAccessToken = %v, want [team-a team-b]", got)
|
||||
}
|
||||
if got := svc.ExtractTeamsFromAccessToken(""); got != nil {
|
||||
t.Errorf("empty token should return nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTeamsFromAccessToken_Malformed(t *testing.T) {
|
||||
svc, err := NewAuthService(&config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{TeamAttributePath: "groups"},
|
||||
}, &config.ServerConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Fewer than two dot-separated segments.
|
||||
if got := svc.ExtractTeamsFromAccessToken("single-segment"); got != nil {
|
||||
t.Errorf("one-segment token = %v, want nil", got)
|
||||
}
|
||||
// Correct shape but the payload segment is not valid base64url.
|
||||
if got := svc.ExtractTeamsFromAccessToken("hdr.!!!not-base64!!!.sig"); got != nil {
|
||||
t.Errorf("bad base64 payload = %v, want nil", got)
|
||||
}
|
||||
// Valid base64url ("bm90anNvbg" -> "notjson") but not JSON.
|
||||
if got := svc.ExtractTeamsFromAccessToken("hdr.bm90anNvbg.sig"); got != nil {
|
||||
t.Errorf("non-JSON payload = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,12 @@ type StateData struct {
|
||||
}
|
||||
|
||||
type SessionClaims struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Roles []string `json:"roles"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Roles []string `json:"roles"`
|
||||
Teams []string `json:"teams,omitempty"`
|
||||
AuthMethod string `json:"auth_method,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -160,10 +162,12 @@ func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (strin
|
||||
expiresAt := now.Add(time.Duration(sessionMaxAge) * time.Second)
|
||||
|
||||
claims := SessionClaims{
|
||||
Username: userInfo.Username,
|
||||
Email: userInfo.Email,
|
||||
Name: userInfo.Name,
|
||||
Roles: userInfo.Roles,
|
||||
Username: userInfo.Username,
|
||||
Email: userInfo.Email,
|
||||
Name: userInfo.Name,
|
||||
Roles: userInfo.Roles,
|
||||
Teams: userInfo.Teams,
|
||||
AuthMethod: userInfo.AuthMethod,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
|
||||
Reference in New Issue
Block a user