Skip to content

Configurable persistence of friendly name to KV #7572

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Apr 1, 2024
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
33 changes: 33 additions & 0 deletions api/authorization.yml
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,39 @@ paths:
default:
$ref: "#/components/responses/ServerError"

/auth/users/{userId}/friendly_name:
parameters:
- in: path
name: userId
required: true
schema:
type: string
put:
tags:
- auth
operationId: updateUserFriendlyName
summary: update users friendly name
requestBody:
required: true
content:
application/json:
schema:
required:
- friendly_name
type: object
properties:
friendly_name:
type: string
responses:
204:
description: friendly name updated succesfully
400:
$ref: "#/components/responses/ValidationError"
401:
$ref: "#/components/responses/Unauthorized"
default:
$ref: "#/components/responses/ServerError"

/auth/groups:
get:
tags:
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,13 @@ This reference uses `.` to denote the nesting of values.
* `auth.cookie_auth_verification.default_initial_groups` (string[] : [])` - By default, users will be assigned to these groups
* `auth.cookie_auth_verification.initial_groups_claim_name` `(string[] : [])` - Use this claim from the ID token to provide the initial group for new users. This will take priority if `auth.cookie_auth_verification.default_initial_groups` is also set.
* `auth.cookie_auth_verification.friendly_name_claim_name` `(string[] : )` - If specified, the value from the claim with this name will be used as the user's display name.
* `auth.cookie_auth_verification.persist_friendly_name` `(string : false)` - If set to `true`, the friendly name is persisted to the KV store and can be displayed in the user list. This is meant to be used in conjunction with `auth.cookie_auth_verification.friendly_name_claim_name`.
* `auth.cookie_auth_verification.external_user_id_claim_name` - `(string : )` - If specified, the value from the claim with this name will be used as the user's id name.
* `auth.cookie_auth_verification.auth_source` - `(string : )` - If specified, user will be labeled with this auth source.
* `auth.oidc.default_initial_groups` `(string[] : [])` - By default, OIDC users will be assigned to these groups
* `auth.oidc.initial_groups_claim_name` `(string[] : [])` - Use this claim from the ID token to provide the initial group for new users. This will take priority if `auth.oidc.default_initial_groups` is also set.
* `auth.oidc.friendly_name_claim_name` `(string[] : )` - If specified, the value from the claim with this name will be used as the user's display name.
* `auth.oidc.persist_friendly_name` `(string : false)` - If set to `true`, the friendly name is persisted to the KV store and can be displayed in the user list. This is meant to be used in conjunction with `auth.oidc.friendly_name_claim_name`.
* `auth.oidc.validate_id_token_claims` `(map[string]string : )` - When a user tries to access lakeFS, validate that the ID token contains these claims with the corresponding values.
* `auth.ui_config.rbac` `(string: "simplified")` - "simplified", "external" or "internal" (enterprise feature). In simplified mode, do not display policy in GUI.
If you have configured an external auth server you can set this to "external" to support the policy editor.
Expand Down
8 changes: 7 additions & 1 deletion docs/reference/security/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,18 @@ auth:
callback_base_url: https://lakefs.example.com # The scheme, domain (and port) of your lakeFS installation
url: https://my-account.oidc-provider-example.com
default_initial_groups: ["Developers"]
friendly_name_claim_name: name # Optional: use the value from this claim as the user's display name
friendly_name_claim_name: name # Optional: use the value from this claim as the user's display name
persist_friendly_name: true # Optional: persist friendly name to KV store so it can be displayed in the user list
```

Your login page will now include a link to sign in using the
OIDC provider. When a user first logs in through the provider, a corresponding user is created in lakeFS.

#### Friendly Name Persistence

When the `persist_friendly_name` configuration property is set to `true` **and** `friendly_name_claim_name` is set to a valid claim name, which exists in the incoming `id_token`, the friendly name will be persisted to the KV store. This will allow users with access to the lakeFS administration section to see friendly names in the users list, when listing group members, and when adding/removing group members.
The friendly name stored in KV is updated with each successful login, if the incoming value is different than the stored value. This means it will be kept up-to-date with changes to the user's profile or if `friendly_name_claim_name` is re-configured.

#### Notes
{: .no_toc}
1. As always, you may choose to provide these configurations using [environment variables]({% link reference/configuration.md %}).
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/security/sso.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ auth:
cookie_auth_verification:
auth_source: saml
friendly_name_claim_name: displayName
persist_friendly_name: true
external_user_id_claim_name: samName
default_initial_groups:
- "Developers"
Expand Down Expand Up @@ -243,6 +244,7 @@ auth:
secret_key: shared-secrey-key
oidc:
friendly_name_claim_name: "name"
persist_friendly_name: true
default_initial_groups: ["Developers"]
ui_config:
login_url: /oidc/login
Expand Down
39 changes: 28 additions & 11 deletions pkg/api/auth_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/routers"
"github.com/getkin/kin-openapi/routers/legacy"
"github.com/go-openapi/swag"
"github.com/gorilla/sessions"
"github.com/treeverse/lakefs/pkg/api/apigen"
"github.com/treeverse/lakefs/pkg/auth"
Expand Down Expand Up @@ -46,6 +47,7 @@ type OIDCConfig struct {
DefaultInitialGroups []string
InitialGroupsClaimName string
FriendlyNameClaimName string
PersistFriendlyName bool
}

type CookieAuthConfig struct {
Expand All @@ -55,6 +57,7 @@ type CookieAuthConfig struct {
FriendlyNameClaimName string
ExternalUserIDClaimName string
AuthSource string
PersistFriendlyName bool
}

func GenericAuthMiddleware(logger logging.Logger, authenticator auth.Authenticator, authService auth.Service, oidcConfig *OIDCConfig, cookieAuthConfig *CookieAuthConfig) (func(next http.Handler) http.Handler, error) {
Expand Down Expand Up @@ -191,9 +194,14 @@ func checkSecurityRequirements(r *http.Request,
return nil, nil
}

func enhanceWithFriendlyName(user *model.User, friendlyName string) *model.User {
if friendlyName != "" {
user.FriendlyName = &friendlyName
func enhanceWithFriendlyName(ctx context.Context, user *model.User, friendlyName string, persistFriendlyName bool, authService auth.Service, logger logging.Logger) *model.User {
if swag.StringValue(user.FriendlyName) != friendlyName {
user.FriendlyName = swag.String(friendlyName)
if persistFriendlyName {
if err := authService.UpdateUserFriendlyName(ctx, user.Username, friendlyName); err != nil {
logger.WithError(err).Error("failed to update user friendly name")
}
}
}
return user
}
Expand Down Expand Up @@ -245,7 +253,7 @@ func userFromSAML(ctx context.Context, logger logging.Logger, authService auth.S
user, err := authService.GetUserByExternalID(ctx, externalID)
if err == nil {
log.Info("Found user")
return enhanceWithFriendlyName(user, friendlyName), nil
return enhanceWithFriendlyName(ctx, user, friendlyName, cookieAuthConfig.PersistFriendlyName, authService, logger), nil
}
if !errors.Is(err, auth.ErrNotFound) {
log.WithError(err).Error("Failed while searching if user exists in database")
Expand All @@ -259,7 +267,9 @@ func userFromSAML(ctx context.Context, logger logging.Logger, authService auth.S
Username: externalID,
ExternalID: &externalID,
}

if cookieAuthConfig.PersistFriendlyName {
u.FriendlyName = &friendlyName
}
_, err = authService.CreateUser(ctx, &u)
if err != nil {
if !errors.Is(err, auth.ErrAlreadyExists) {
Expand All @@ -272,7 +282,7 @@ func userFromSAML(ctx context.Context, logger logging.Logger, authService auth.S
log.WithError(err).Error("failed to get external user from database")
return nil, ErrAuthenticatingRequest
}
return enhanceWithFriendlyName(user, friendlyName), nil
return enhanceWithFriendlyName(ctx, user, friendlyName, cookieAuthConfig.PersistFriendlyName, authService, logger), nil
}
initialGroups := cookieAuthConfig.DefaultInitialGroups
if userInitialGroups, ok := idTokenClaims[cookieAuthConfig.InitialGroupsClaimName].(string); ok {
Expand All @@ -284,7 +294,9 @@ func userFromSAML(ctx context.Context, logger logging.Logger, authService auth.S
log.WithError(err).Error("Failed to add external user to group")
}
}
return enhanceWithFriendlyName(&u, friendlyName), nil
// The user was just created.
// Regardless of the value of PersistFriendlyName, we don't need to update their friendly name if we got here.
return enhanceWithFriendlyName(ctx, user, friendlyName, false, authService, logger), nil
}

// userFromOIDC returns a user from an existing OIDC session.
Expand All @@ -295,7 +307,7 @@ func userFromOIDC(ctx context.Context, logger logging.Logger, authService auth.S
if idTokenClaims == nil {
return nil, nil
}
if !ok || idTokenClaims == nil {
if !ok {
return nil, ErrAuthenticatingRequest
}
externalID, ok := idTokenClaims["sub"].(string)
Expand All @@ -321,7 +333,7 @@ func userFromOIDC(ctx context.Context, logger logging.Logger, authService auth.S
}
user, err := authService.GetUserByExternalID(ctx, externalID)
if err == nil {
return enhanceWithFriendlyName(user, friendlyName), nil
return enhanceWithFriendlyName(ctx, user, friendlyName, oidcConfig.PersistFriendlyName, authService, logger), nil
}
if !errors.Is(err, auth.ErrNotFound) {
logger.WithError(err).Error("Failed to get external user from database")
Expand All @@ -333,6 +345,9 @@ func userFromOIDC(ctx context.Context, logger logging.Logger, authService auth.S
Username: externalID,
ExternalID: &externalID,
}
if oidcConfig.PersistFriendlyName {
u.FriendlyName = &friendlyName
}
_, err = authService.CreateUser(ctx, &u)
if err != nil {
if !errors.Is(err, auth.ErrAlreadyExists) {
Expand All @@ -345,7 +360,7 @@ func userFromOIDC(ctx context.Context, logger logging.Logger, authService auth.S
logger.WithError(err).Error("Failed to get external user from database")
return nil, ErrAuthenticatingRequest
}
return enhanceWithFriendlyName(user, friendlyName), nil
return enhanceWithFriendlyName(ctx, user, friendlyName, oidcConfig.PersistFriendlyName, authService, logger), nil
}
initialGroups := oidcConfig.DefaultInitialGroups
if userInitialGroups, ok := idTokenClaims[oidcConfig.InitialGroupsClaimName].(string); ok {
Expand All @@ -357,7 +372,9 @@ func userFromOIDC(ctx context.Context, logger logging.Logger, authService auth.S
logger.WithError(err).Error("Failed to add external user to group")
}
}
return enhanceWithFriendlyName(&u, friendlyName), nil
// The user was just created.
// Regardless of the value of PersistFriendlyName, we don't need to update their friendly name if we got here.
return enhanceWithFriendlyName(ctx, user, friendlyName, false, authService, logger), nil
}

func userByToken(ctx context.Context, logger logging.Logger, authService auth.Service, tokenString string) (*model.User, error) {
Expand Down
40 changes: 40 additions & 0 deletions pkg/auth/mock/mock_auth_client.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions pkg/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ type Service interface {
GetUserByExternalID(ctx context.Context, externalID string) (*model.User, error)
GetUserByEmail(ctx context.Context, email string) (*model.User, error)
ListUsers(ctx context.Context, params *model.PaginationParams) ([]*model.User, *model.Paginator, error)
UpdateUserFriendlyName(ctx context.Context, userID string, friendlyName string) error

ExternalPrincipalsService

Expand Down Expand Up @@ -360,6 +361,10 @@ func (s *AuthService) ListUsers(ctx context.Context, params *model.PaginationPar
return model.ConvertUsersDataList(msgs), paginator, err
}

func (s *AuthService) UpdateUserFriendlyName(ctx context.Context, userID string, friendlyName string) error {
return ErrNotImplemented
}

func (s *AuthService) ListUserCredentials(ctx context.Context, username string, params *model.PaginationParams) ([]*model.Credential, *model.Paginator, error) {
var credential model.CredentialData
credentialsKey := model.CredentialPath(username, params.Prefix)
Expand Down Expand Up @@ -1367,6 +1372,17 @@ func (a *APIAuthService) ListUsers(ctx context.Context, params *model.Pagination
return users, toPagination(pagination), nil
}

func (a *APIAuthService) UpdateUserFriendlyName(ctx context.Context, userID string, friendlyName string) error {
resp, err := a.apiClient.UpdateUserFriendlyNameWithResponse(ctx, userID, UpdateUserFriendlyNameJSONRequestBody{
FriendlyName: friendlyName,
})
if err != nil {
a.logger.WithError(err).WithField("userID", userID).Error("update user friendly name")
return err
}
return a.validateResponse(resp, http.StatusNoContent)
}

func (a *APIAuthService) CreateGroup(ctx context.Context, group *model.Group) (*model.Group, error) {
resp, err := a.apiClient.CreateGroupWithResponse(ctx, CreateGroupJSONRequestBody{
Id: group.DisplayName,
Expand Down
3 changes: 3 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type OIDC struct {
DefaultInitialGroups []string `mapstructure:"default_initial_groups"`
InitialGroupsClaimName string `mapstructure:"initial_groups_claim_name"`
FriendlyNameClaimName string `mapstructure:"friendly_name_claim_name"`
PersistFriendlyName bool `mapstructure:"persist_friendly_name"`
}

// CookieAuthVerification is related to auth based on a cookie set by an external service
Expand All @@ -52,6 +53,8 @@ type CookieAuthVerification struct {
ExternalUserIDClaimName string `mapstructure:"external_user_id_claim_name"`
// AuthSource tag each user with label of the IDP
AuthSource string `mapstructure:"auth_source"`
// PersistFriendlyName should we persist the friendly name in the KV store
PersistFriendlyName bool `mapstructure:"persist_friendly_name"`
}

// S3AuthInfo holds S3-style authentication.
Expand Down
2 changes: 2 additions & 0 deletions pkg/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ func setDefaults(cfgType string) {
viper.SetDefault("auth.remote_authenticator.request_timeout", 10*time.Second)

viper.SetDefault("auth.api.health_check_timeout", DefaultAuthAPIHealthCheckTimeout)
viper.SetDefault("auth.oidc.persist_friendly_name", false)
viper.SetDefault("auth.cookie_auth_verification.persist_friendly_name", false)

viper.SetDefault("blockstore.local.path", "~/lakefs/data/block")
viper.SetDefault("blockstore.s3.region", "us-east-1")
Expand Down
12 changes: 12 additions & 0 deletions webui/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
interface User {
id: string;
email: string;
friendly_name: string;
}

export const resolveDisplayName = (user: User): string => {
if (!user) return "";
if (user?.email?.length) return user.email;
if (user?.friendly_name?.length) return user.friendly_name;
return user.id;
};
9 changes: 2 additions & 7 deletions webui/src/pages/auth/credentials.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,7 @@ import {auth} from "../../lib/api";
import {useState} from "react";
import {CredentialsShowModal, CredentialsTable} from "../../lib/components/auth/credentials";
import {useRouter} from "../../lib/hooks/router";

const resolveDisplayName = (user) => {
if (!user) return "";
if (user?.email?.length) return user.email;
return user.id;
}
import {resolveDisplayName} from "../../lib/utils";

const CredentialsContainer = () => {
const router = useRouter();
Expand Down Expand Up @@ -90,4 +85,4 @@ const CredentialsPage = () => {
};


export default CredentialsPage;
export default CredentialsPage;
5 changes: 3 additions & 2 deletions webui/src/pages/auth/groups/group/members.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "../../../../lib/components/controls";
import {useRouter} from "../../../../lib/hooks/router";
import {Link} from "../../../../lib/components/nav";
import {resolveDisplayName} from "../../../../lib/utils";


const GroupMemberList = ({ groupId, after, onPaginate }) => {
Expand All @@ -44,7 +45,7 @@ const GroupMemberList = ({ groupId, after, onPaginate }) => {
<DataTable
keyFn={user => user.id}
rowFn={user => [
<Link href={{pathname: '/auth/users/:userId', params: {userId: user.id}}}>{user.email || user.id}</Link>,
<Link href={{pathname: '/auth/users/:userId', params: {userId: user.id}}}>{resolveDisplayName(user)}</Link>,
<FormattedDate dateValue={user.creation_date}/>
]}
headers={['User ID', 'Created At']}
Expand All @@ -53,7 +54,7 @@ const GroupMemberList = ({ groupId, after, onPaginate }) => {
buttonFn: user => <ConfirmationButton
size="sm"
variant="outline-danger"
msg={<span>Are you sure you{'\''}d like to remove user <strong>{user.email || user.id}</strong> from group <strong>{groupId}</strong>?</span>}
msg={<span>Are you sure you{'\''}d like to remove user <strong>{resolveDisplayName(user)}</strong> from group <strong>{groupId}</strong>?</span>}
onConfirm={() => {
auth.removeUserFromGroup(user.id, groupId)
.catch(error => alert(error))
Expand Down
Loading
Loading