mirror of https://github.com/coder/coder.git
feat: add auto group create from OIDC (#8884)
* add flag for auto create groups * fixup! add flag for auto create groups * sync missing groups Also added a regex filter to filter out groups that are not important
This commit is contained in:
parent
4a987e9917
commit
f4122fa9f5
|
@ -72,6 +72,40 @@ func TestOptionSet_ParseFlags(t *testing.T) {
|
|||
err := os.FlagSet().Parse([]string{"--some-unknown", "foo"})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("RegexValid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var regexpString clibase.Regexp
|
||||
|
||||
os := clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "RegexpString",
|
||||
Value: ®expString,
|
||||
Flag: "regexp-string",
|
||||
},
|
||||
}
|
||||
|
||||
err := os.FlagSet().Parse([]string{"--regexp-string", "$test^"})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("RegexInvalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var regexpString clibase.Regexp
|
||||
|
||||
os := clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "RegexpString",
|
||||
Value: ®expString,
|
||||
Flag: "regexp-string",
|
||||
},
|
||||
}
|
||||
|
||||
err := os.FlagSet().Parse([]string{"--regexp-string", "(("})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOptionSet_ParseEnv(t *testing.T) {
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"net"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -461,6 +462,43 @@ func (e *Enum) String() string {
|
|||
return *e.Value
|
||||
}
|
||||
|
||||
type Regexp regexp.Regexp
|
||||
|
||||
func (r *Regexp) MarshalYAML() (interface{}, error) {
|
||||
return yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: r.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Regexp) UnmarshalYAML(n *yaml.Node) error {
|
||||
return r.Set(n.Value)
|
||||
}
|
||||
|
||||
func (r *Regexp) Set(v string) error {
|
||||
exp, err := regexp.Compile(v)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("invalid regex expression: %w", err)
|
||||
}
|
||||
*r = Regexp(*exp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r Regexp) String() string {
|
||||
return r.Value().String()
|
||||
}
|
||||
|
||||
func (r *Regexp) Value() *regexp.Regexp {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return (*regexp.Regexp)(r)
|
||||
}
|
||||
|
||||
func (Regexp) Type() string {
|
||||
return "regexp"
|
||||
}
|
||||
|
||||
var _ pflag.Value = (*YAMLConfigPath)(nil)
|
||||
|
||||
// YAMLConfigPath is a special value type that encodes a path to a YAML
|
||||
|
|
|
@ -597,6 +597,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
|||
AuthURLParams: cfg.OIDC.AuthURLParams.Value,
|
||||
IgnoreUserInfo: cfg.OIDC.IgnoreUserInfo.Value(),
|
||||
GroupField: cfg.OIDC.GroupField.String(),
|
||||
GroupFilter: cfg.OIDC.GroupRegexFilter.Value(),
|
||||
CreateMissingGroups: cfg.OIDC.GroupAutoCreate.Value(),
|
||||
GroupMapping: cfg.OIDC.GroupMapping.Value,
|
||||
UserRoleField: cfg.OIDC.UserRoleField.String(),
|
||||
UserRoleMapping: cfg.OIDC.UserRoleMapping.Value,
|
||||
|
|
|
@ -298,6 +298,9 @@ can safely ignore these settings.
|
|||
GitHub.
|
||||
|
||||
[1mOIDC Options[0m
|
||||
--oidc-group-auto-create bool, $CODER_OIDC_GROUP_AUTO_CREATE (default: false)
|
||||
Automatically creates missing groups from a user's groups claim.
|
||||
|
||||
--oidc-allow-signups bool, $CODER_OIDC_ALLOW_SIGNUPS (default: true)
|
||||
Whether new users can sign up with OIDC.
|
||||
|
||||
|
@ -334,6 +337,11 @@ can safely ignore these settings.
|
|||
--oidc-issuer-url string, $CODER_OIDC_ISSUER_URL
|
||||
Issuer URL to use for Login with OIDC.
|
||||
|
||||
--oidc-group-regex-filter regexp, $CODER_OIDC_GROUP_REGEX_FILTER (default: .*)
|
||||
If provided any group name not matching the regex is ignored. This
|
||||
allows for filtering out groups that are not needed. This filter is
|
||||
applied after the group mapping.
|
||||
|
||||
--oidc-scopes string-array, $CODER_OIDC_SCOPES (default: openid,profile,email)
|
||||
Scopes to grant when authenticating with OIDC.
|
||||
|
||||
|
|
|
@ -271,6 +271,14 @@ oidc:
|
|||
# for when OIDC providers only return group IDs.
|
||||
# (default: {}, type: struct[map[string]string])
|
||||
groupMapping: {}
|
||||
# Automatically creates missing groups from a user's groups claim.
|
||||
# (default: false, type: bool)
|
||||
enableGroupAutoCreate: false
|
||||
# If provided any group name not matching the regex is ignored. This allows for
|
||||
# filtering out groups that are not needed. This filter is applied after the group
|
||||
# mapping.
|
||||
# (default: .*, type: regexp)
|
||||
groupRegexFilter: .*
|
||||
# This field must be set if using the user roles sync feature. Set this to the
|
||||
# name of the claim used to store the user's role. The roles should be sent as an
|
||||
# array of strings.
|
||||
|
|
|
@ -6624,6 +6624,9 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"clibase.Regexp": {
|
||||
"type": "object"
|
||||
},
|
||||
"clibase.Struct-array_codersdk_GitAuthConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -8274,9 +8277,23 @@ const docTemplate = `{
|
|||
},
|
||||
"quota_allowance": {
|
||||
"type": "integer"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/definitions/codersdk.GroupSource"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.GroupSource": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"user",
|
||||
"oidc"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"GroupSourceUser",
|
||||
"GroupSourceOIDC"
|
||||
]
|
||||
},
|
||||
"codersdk.Healthcheck": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -8583,9 +8600,15 @@ const docTemplate = `{
|
|||
"email_field": {
|
||||
"type": "string"
|
||||
},
|
||||
"group_auto_create": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"group_mapping": {
|
||||
"type": "object"
|
||||
},
|
||||
"group_regex_filter": {
|
||||
"$ref": "#/definitions/clibase.Regexp"
|
||||
},
|
||||
"groups_field": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
@ -5874,6 +5874,9 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"clibase.Regexp": {
|
||||
"type": "object"
|
||||
},
|
||||
"clibase.Struct-array_codersdk_GitAuthConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -7430,9 +7433,17 @@
|
|||
},
|
||||
"quota_allowance": {
|
||||
"type": "integer"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/definitions/codersdk.GroupSource"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.GroupSource": {
|
||||
"type": "string",
|
||||
"enum": ["user", "oidc"],
|
||||
"x-enum-varnames": ["GroupSourceUser", "GroupSourceOIDC"]
|
||||
},
|
||||
"codersdk.Healthcheck": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -7703,9 +7714,15 @@
|
|||
"email_field": {
|
||||
"type": "string"
|
||||
},
|
||||
"group_auto_create": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"group_mapping": {
|
||||
"type": "object"
|
||||
},
|
||||
"group_regex_filter": {
|
||||
"$ref": "#/definitions/clibase.Regexp"
|
||||
},
|
||||
"groups_field": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
@ -127,8 +127,8 @@ type Options struct {
|
|||
BaseDERPMap *tailcfg.DERPMap
|
||||
DERPMapUpdateFrequency time.Duration
|
||||
SwaggerEndpoint bool
|
||||
SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error
|
||||
SetUserSiteRoles func(ctx context.Context, tx database.Store, userID uuid.UUID, roles []string) error
|
||||
SetUserGroups func(ctx context.Context, logger slog.Logger, tx database.Store, userID uuid.UUID, groupNames []string, createMissingGroups bool) error
|
||||
SetUserSiteRoles func(ctx context.Context, logger slog.Logger, tx database.Store, userID uuid.UUID, roles []string) error
|
||||
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
|
||||
UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore]
|
||||
// AppSecurityKey is the crypto key used to sign and encrypt tokens related to
|
||||
|
@ -262,16 +262,16 @@ func New(options *Options) *API {
|
|||
options.TracerProvider = trace.NewNoopTracerProvider()
|
||||
}
|
||||
if options.SetUserGroups == nil {
|
||||
options.SetUserGroups = func(ctx context.Context, _ database.Store, userID uuid.UUID, groups []string) error {
|
||||
options.Logger.Warn(ctx, "attempted to assign OIDC groups without enterprise license",
|
||||
slog.F("user_id", userID), slog.F("groups", groups),
|
||||
options.SetUserGroups = func(ctx context.Context, logger slog.Logger, _ database.Store, userID uuid.UUID, groups []string, createMissingGroups bool) error {
|
||||
logger.Warn(ctx, "attempted to assign OIDC groups without enterprise license",
|
||||
slog.F("user_id", userID), slog.F("groups", groups), slog.F("create_missing_groups", createMissingGroups),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if options.SetUserSiteRoles == nil {
|
||||
options.SetUserSiteRoles = func(ctx context.Context, _ database.Store, userID uuid.UUID, roles []string) error {
|
||||
options.Logger.Warn(ctx, "attempted to assign OIDC user roles without enterprise license",
|
||||
options.SetUserSiteRoles = func(ctx context.Context, logger slog.Logger, _ database.Store, userID uuid.UUID, roles []string) error {
|
||||
logger.Warn(ctx, "attempted to assign OIDC user roles without enterprise license",
|
||||
slog.F("user_id", userID), slog.F("roles", roles),
|
||||
)
|
||||
return nil
|
||||
|
|
|
@ -1853,6 +1853,13 @@ func (q *querier) InsertLicense(ctx context.Context, arg database.InsertLicenseP
|
|||
return q.db.InsertLicense(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertMissingGroups(ctx context.Context, arg database.InsertMissingGroupsParams) ([]database.Group, error) {
|
||||
if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceSystem); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.InsertMissingGroups(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertOrganization(ctx context.Context, arg database.InsertOrganizationParams) (database.Organization, error) {
|
||||
return insert(q.log, q.auth, rbac.ResourceOrganization, q.db.InsertOrganization)(ctx, arg)
|
||||
}
|
||||
|
|
|
@ -3641,6 +3641,7 @@ func (q *FakeQuerier) InsertGroup(_ context.Context, arg database.InsertGroupPar
|
|||
OrganizationID: arg.OrganizationID,
|
||||
AvatarURL: arg.AvatarURL,
|
||||
QuotaAllowance: arg.QuotaAllowance,
|
||||
Source: database.GroupSourceUser,
|
||||
}
|
||||
|
||||
q.groups = append(q.groups, group)
|
||||
|
@ -3693,6 +3694,45 @@ func (q *FakeQuerier) InsertLicense(
|
|||
return l, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) InsertMissingGroups(_ context.Context, arg database.InsertMissingGroupsParams) ([]database.Group, error) {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groupNameMap := make(map[string]struct{})
|
||||
for _, g := range arg.GroupNames {
|
||||
groupNameMap[g] = struct{}{}
|
||||
}
|
||||
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
for _, g := range q.groups {
|
||||
if g.OrganizationID != arg.OrganizationID {
|
||||
continue
|
||||
}
|
||||
delete(groupNameMap, g.Name)
|
||||
}
|
||||
|
||||
newGroups := make([]database.Group, 0, len(groupNameMap))
|
||||
for k := range groupNameMap {
|
||||
g := database.Group{
|
||||
ID: uuid.New(),
|
||||
Name: k,
|
||||
OrganizationID: arg.OrganizationID,
|
||||
AvatarURL: "",
|
||||
QuotaAllowance: 0,
|
||||
DisplayName: "",
|
||||
Source: arg.Source,
|
||||
}
|
||||
q.groups = append(q.groups, g)
|
||||
newGroups = append(newGroups, g)
|
||||
}
|
||||
|
||||
return newGroups, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) InsertOrganization(_ context.Context, arg database.InsertOrganizationParams) (database.Organization, error) {
|
||||
if err := validateDatabaseType(arg); err != nil {
|
||||
return database.Organization{}, err
|
||||
|
|
|
@ -1110,6 +1110,13 @@ func (m metricsStore) InsertLicense(ctx context.Context, arg database.InsertLice
|
|||
return license, err
|
||||
}
|
||||
|
||||
func (m metricsStore) InsertMissingGroups(ctx context.Context, arg database.InsertMissingGroupsParams) ([]database.Group, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.InsertMissingGroups(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("InsertMissingGroups").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m metricsStore) InsertOrganization(ctx context.Context, arg database.InsertOrganizationParams) (database.Organization, error) {
|
||||
start := time.Now()
|
||||
organization, err := m.s.InsertOrganization(ctx, arg)
|
||||
|
|
|
@ -2332,6 +2332,21 @@ func (mr *MockStoreMockRecorder) InsertLicense(arg0, arg1 interface{}) *gomock.C
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertLicense", reflect.TypeOf((*MockStore)(nil).InsertLicense), arg0, arg1)
|
||||
}
|
||||
|
||||
// InsertMissingGroups mocks base method.
|
||||
func (m *MockStore) InsertMissingGroups(arg0 context.Context, arg1 database.InsertMissingGroupsParams) ([]database.Group, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "InsertMissingGroups", arg0, arg1)
|
||||
ret0, _ := ret[0].([]database.Group)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// InsertMissingGroups indicates an expected call of InsertMissingGroups.
|
||||
func (mr *MockStoreMockRecorder) InsertMissingGroups(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertMissingGroups", reflect.TypeOf((*MockStore)(nil).InsertMissingGroups), arg0, arg1)
|
||||
}
|
||||
|
||||
// InsertOrganization mocks base method.
|
||||
func (m *MockStore) InsertOrganization(arg0 context.Context, arg1 database.InsertOrganizationParams) (database.Organization, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
|
|
@ -31,6 +31,11 @@ CREATE TYPE build_reason AS ENUM (
|
|||
'autodelete'
|
||||
);
|
||||
|
||||
CREATE TYPE group_source AS ENUM (
|
||||
'user',
|
||||
'oidc'
|
||||
);
|
||||
|
||||
CREATE TYPE log_level AS ENUM (
|
||||
'trace',
|
||||
'debug',
|
||||
|
@ -299,11 +304,14 @@ CREATE TABLE groups (
|
|||
organization_id uuid NOT NULL,
|
||||
avatar_url text DEFAULT ''::text NOT NULL,
|
||||
quota_allowance integer DEFAULT 0 NOT NULL,
|
||||
display_name text DEFAULT ''::text NOT NULL
|
||||
display_name text DEFAULT ''::text NOT NULL,
|
||||
source group_source DEFAULT 'user'::group_source NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN groups.display_name IS 'Display name is a custom, human-friendly group name that user can set. This is not required to be unique and can be the empty string.';
|
||||
|
||||
COMMENT ON COLUMN groups.source IS 'Source indicates how the group was created. It can be created by a user manually, or through some system process like OIDC group sync.';
|
||||
|
||||
CREATE TABLE licenses (
|
||||
id integer NOT NULL,
|
||||
uploaded_at timestamp with time zone NOT NULL,
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
BEGIN;
|
||||
|
||||
ALTER TABLE groups
|
||||
DROP COLUMN source;
|
||||
|
||||
DROP TYPE group_source;
|
||||
|
||||
COMMIT;
|
|
@ -0,0 +1,15 @@
|
|||
BEGIN;
|
||||
|
||||
CREATE TYPE group_source AS ENUM (
|
||||
-- User created groups
|
||||
'user',
|
||||
-- Groups created by the system through oidc sync
|
||||
'oidc'
|
||||
);
|
||||
|
||||
ALTER TABLE groups
|
||||
ADD COLUMN source group_source NOT NULL DEFAULT 'user';
|
||||
|
||||
COMMENT ON COLUMN groups.source IS 'Source indicates how the group was created. It can be created by a user manually, or through some system process like OIDC group sync.';
|
||||
|
||||
COMMIT;
|
|
@ -281,6 +281,64 @@ func AllBuildReasonValues() []BuildReason {
|
|||
}
|
||||
}
|
||||
|
||||
type GroupSource string
|
||||
|
||||
const (
|
||||
GroupSourceUser GroupSource = "user"
|
||||
GroupSourceOidc GroupSource = "oidc"
|
||||
)
|
||||
|
||||
func (e *GroupSource) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = GroupSource(s)
|
||||
case string:
|
||||
*e = GroupSource(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for GroupSource: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullGroupSource struct {
|
||||
GroupSource GroupSource `json:"group_source"`
|
||||
Valid bool `json:"valid"` // Valid is true if GroupSource is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullGroupSource) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.GroupSource, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.GroupSource.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullGroupSource) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.GroupSource), nil
|
||||
}
|
||||
|
||||
func (e GroupSource) Valid() bool {
|
||||
switch e {
|
||||
case GroupSourceUser,
|
||||
GroupSourceOidc:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func AllGroupSourceValues() []GroupSource {
|
||||
return []GroupSource{
|
||||
GroupSourceUser,
|
||||
GroupSourceOidc,
|
||||
}
|
||||
}
|
||||
|
||||
type LogLevel string
|
||||
|
||||
const (
|
||||
|
@ -1498,6 +1556,8 @@ type Group struct {
|
|||
QuotaAllowance int32 `db:"quota_allowance" json:"quota_allowance"`
|
||||
// Display name is a custom, human-friendly group name that user can set. This is not required to be unique and can be the empty string.
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
// Source indicates how the group was created. It can be created by a user manually, or through some system process like OIDC group sync.
|
||||
Source GroupSource `db:"source" json:"source"`
|
||||
}
|
||||
|
||||
type GroupMember struct {
|
||||
|
|
|
@ -206,6 +206,11 @@ type sqlcQuerier interface {
|
|||
InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error)
|
||||
InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error
|
||||
InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error)
|
||||
// Inserts any group by name that does not exist. All new groups are given
|
||||
// a random uuid, are inserted into the same organization. They have the default
|
||||
// values for avatar, display name, and quota allowance (all zero values).
|
||||
// If the name conflicts, do nothing.
|
||||
InsertMissingGroups(ctx context.Context, arg InsertMissingGroupsParams) ([]Group, error)
|
||||
InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error)
|
||||
InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error)
|
||||
InsertProvisionerDaemon(ctx context.Context, arg InsertProvisionerDaemonParams) (ProvisionerDaemon, error)
|
||||
|
|
|
@ -1180,7 +1180,7 @@ func (q *sqlQuerier) DeleteGroupByID(ctx context.Context, id uuid.UUID) error {
|
|||
|
||||
const getGroupByID = `-- name: GetGroupByID :one
|
||||
SELECT
|
||||
id, name, organization_id, avatar_url, quota_allowance, display_name
|
||||
id, name, organization_id, avatar_url, quota_allowance, display_name, source
|
||||
FROM
|
||||
groups
|
||||
WHERE
|
||||
|
@ -1199,13 +1199,14 @@ func (q *sqlQuerier) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, err
|
|||
&i.AvatarURL,
|
||||
&i.QuotaAllowance,
|
||||
&i.DisplayName,
|
||||
&i.Source,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getGroupByOrgAndName = `-- name: GetGroupByOrgAndName :one
|
||||
SELECT
|
||||
id, name, organization_id, avatar_url, quota_allowance, display_name
|
||||
id, name, organization_id, avatar_url, quota_allowance, display_name, source
|
||||
FROM
|
||||
groups
|
||||
WHERE
|
||||
|
@ -1231,13 +1232,14 @@ func (q *sqlQuerier) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrg
|
|||
&i.AvatarURL,
|
||||
&i.QuotaAllowance,
|
||||
&i.DisplayName,
|
||||
&i.Source,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getGroupsByOrganizationID = `-- name: GetGroupsByOrganizationID :many
|
||||
SELECT
|
||||
id, name, organization_id, avatar_url, quota_allowance, display_name
|
||||
id, name, organization_id, avatar_url, quota_allowance, display_name, source
|
||||
FROM
|
||||
groups
|
||||
WHERE
|
||||
|
@ -1262,6 +1264,7 @@ func (q *sqlQuerier) GetGroupsByOrganizationID(ctx context.Context, organization
|
|||
&i.AvatarURL,
|
||||
&i.QuotaAllowance,
|
||||
&i.DisplayName,
|
||||
&i.Source,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -1283,7 +1286,7 @@ INSERT INTO groups (
|
|||
organization_id
|
||||
)
|
||||
VALUES
|
||||
($1, 'Everyone', $1) RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name
|
||||
($1, 'Everyone', $1) RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name, source
|
||||
`
|
||||
|
||||
// We use the organization_id as the id
|
||||
|
@ -1299,6 +1302,7 @@ func (q *sqlQuerier) InsertAllUsersGroup(ctx context.Context, organizationID uui
|
|||
&i.AvatarURL,
|
||||
&i.QuotaAllowance,
|
||||
&i.DisplayName,
|
||||
&i.Source,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -1313,7 +1317,7 @@ INSERT INTO groups (
|
|||
quota_allowance
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6) RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name
|
||||
($1, $2, $3, $4, $5, $6) RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name, source
|
||||
`
|
||||
|
||||
type InsertGroupParams struct {
|
||||
|
@ -1342,10 +1346,70 @@ func (q *sqlQuerier) InsertGroup(ctx context.Context, arg InsertGroupParams) (Gr
|
|||
&i.AvatarURL,
|
||||
&i.QuotaAllowance,
|
||||
&i.DisplayName,
|
||||
&i.Source,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertMissingGroups = `-- name: InsertMissingGroups :many
|
||||
INSERT INTO groups (
|
||||
id,
|
||||
name,
|
||||
organization_id,
|
||||
source
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
group_name,
|
||||
$1,
|
||||
$2
|
||||
FROM
|
||||
UNNEST($3 :: text[]) AS group_name
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name, source
|
||||
`
|
||||
|
||||
type InsertMissingGroupsParams struct {
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
Source GroupSource `db:"source" json:"source"`
|
||||
GroupNames []string `db:"group_names" json:"group_names"`
|
||||
}
|
||||
|
||||
// Inserts any group by name that does not exist. All new groups are given
|
||||
// a random uuid, are inserted into the same organization. They have the default
|
||||
// values for avatar, display name, and quota allowance (all zero values).
|
||||
// If the name conflicts, do nothing.
|
||||
func (q *sqlQuerier) InsertMissingGroups(ctx context.Context, arg InsertMissingGroupsParams) ([]Group, error) {
|
||||
rows, err := q.db.QueryContext(ctx, insertMissingGroups, arg.OrganizationID, arg.Source, pq.Array(arg.GroupNames))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Group
|
||||
for rows.Next() {
|
||||
var i Group
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.OrganizationID,
|
||||
&i.AvatarURL,
|
||||
&i.QuotaAllowance,
|
||||
&i.DisplayName,
|
||||
&i.Source,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateGroupByID = `-- name: UpdateGroupByID :one
|
||||
UPDATE
|
||||
groups
|
||||
|
@ -1356,7 +1420,7 @@ SET
|
|||
quota_allowance = $4
|
||||
WHERE
|
||||
id = $5
|
||||
RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name
|
||||
RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name, source
|
||||
`
|
||||
|
||||
type UpdateGroupByIDParams struct {
|
||||
|
@ -1383,6 +1447,7 @@ func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDPar
|
|||
&i.AvatarURL,
|
||||
&i.QuotaAllowance,
|
||||
&i.DisplayName,
|
||||
&i.Source,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
@ -42,6 +42,28 @@ INSERT INTO groups (
|
|||
VALUES
|
||||
($1, $2, $3, $4, $5, $6) RETURNING *;
|
||||
|
||||
-- name: InsertMissingGroups :many
|
||||
-- Inserts any group by name that does not exist. All new groups are given
|
||||
-- a random uuid, are inserted into the same organization. They have the default
|
||||
-- values for avatar, display name, and quota allowance (all zero values).
|
||||
INSERT INTO groups (
|
||||
id,
|
||||
name,
|
||||
organization_id,
|
||||
source
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
group_name,
|
||||
@organization_id,
|
||||
@source
|
||||
FROM
|
||||
UNNEST(@group_names :: text[]) AS group_name
|
||||
-- If the name conflicts, do nothing.
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING *;
|
||||
|
||||
|
||||
-- We use the organization_id as the id
|
||||
-- for simplicity since all users is
|
||||
-- every member of the org.
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
@ -688,6 +689,13 @@ type OIDCConfig struct {
|
|||
// groups. If the group field is the empty string, then no group updates
|
||||
// will ever come from the OIDC provider.
|
||||
GroupField string
|
||||
// CreateMissingGroups controls whether groups returned by the OIDC provider
|
||||
// are automatically created in Coder if they are missing.
|
||||
CreateMissingGroups bool
|
||||
// GroupFilter is a regular expression that filters the groups returned by
|
||||
// the OIDC provider. Any group not matched by this regex will be ignored.
|
||||
// If the group filter is nil, then no group filtering will occur.
|
||||
GroupFilter *regexp.Regexp
|
||||
// GroupMapping controls how groups returned by the OIDC provider get mapped
|
||||
// to groups within Coder.
|
||||
// map[oidcGroupName]coderGroupName
|
||||
|
@ -1029,19 +1037,21 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
params := (&oauthLoginParams{
|
||||
User: user,
|
||||
Link: link,
|
||||
State: state,
|
||||
LinkedID: oidcLinkedID(idToken),
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
AllowSignups: api.OIDCConfig.AllowSignups,
|
||||
Email: email,
|
||||
Username: username,
|
||||
AvatarURL: picture,
|
||||
UsingGroups: usingGroups,
|
||||
UsingRoles: api.OIDCConfig.RoleSyncEnabled(),
|
||||
Roles: roles,
|
||||
Groups: groups,
|
||||
User: user,
|
||||
Link: link,
|
||||
State: state,
|
||||
LinkedID: oidcLinkedID(idToken),
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
AllowSignups: api.OIDCConfig.AllowSignups,
|
||||
Email: email,
|
||||
Username: username,
|
||||
AvatarURL: picture,
|
||||
UsingGroups: usingGroups,
|
||||
UsingRoles: api.OIDCConfig.RoleSyncEnabled(),
|
||||
Roles: roles,
|
||||
Groups: groups,
|
||||
CreateMissingGroups: api.OIDCConfig.CreateMissingGroups,
|
||||
GroupFilter: api.OIDCConfig.GroupFilter,
|
||||
}).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) {
|
||||
return audit.InitRequest[database.User](rw, params)
|
||||
})
|
||||
|
@ -1125,8 +1135,10 @@ type oauthLoginParams struct {
|
|||
AvatarURL string
|
||||
// Is UsingGroups is true, then the user will be assigned
|
||||
// to the Groups provided.
|
||||
UsingGroups bool
|
||||
Groups []string
|
||||
UsingGroups bool
|
||||
CreateMissingGroups bool
|
||||
Groups []string
|
||||
GroupFilter *regexp.Regexp
|
||||
// Is UsingRoles is true, then the user will be assigned
|
||||
// the roles provided.
|
||||
UsingRoles bool
|
||||
|
@ -1342,8 +1354,18 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
|
|||
|
||||
// Ensure groups are correct.
|
||||
if params.UsingGroups {
|
||||
filtered := params.Groups
|
||||
if params.GroupFilter != nil {
|
||||
filtered = make([]string, 0, len(params.Groups))
|
||||
for _, group := range params.Groups {
|
||||
if params.GroupFilter.MatchString(group) {
|
||||
filtered = append(filtered, group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:gocritic
|
||||
err := api.Options.SetUserGroups(dbauthz.AsSystemRestricted(ctx), tx, user.ID, params.Groups)
|
||||
err := api.Options.SetUserGroups(dbauthz.AsSystemRestricted(ctx), logger, tx, user.ID, filtered, params.CreateMissingGroups)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("set user groups: %w", err)
|
||||
}
|
||||
|
@ -1362,7 +1384,7 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
|
|||
}
|
||||
|
||||
//nolint:gocritic
|
||||
err := api.Options.SetUserSiteRoles(dbauthz.AsSystemRestricted(ctx), tx, user.ID, filtered)
|
||||
err := api.Options.SetUserSiteRoles(dbauthz.AsSystemRestricted(ctx), logger, tx, user.ID, filtered)
|
||||
if err != nil {
|
||||
return httpError{
|
||||
code: http.StatusBadRequest,
|
||||
|
|
|
@ -271,6 +271,8 @@ type OIDCConfig struct {
|
|||
EmailField clibase.String `json:"email_field" typescript:",notnull"`
|
||||
AuthURLParams clibase.Struct[map[string]string] `json:"auth_url_params" typescript:",notnull"`
|
||||
IgnoreUserInfo clibase.Bool `json:"ignore_user_info" typescript:",notnull"`
|
||||
GroupAutoCreate clibase.Bool `json:"group_auto_create" typescript:",notnull"`
|
||||
GroupRegexFilter clibase.Regexp `json:"group_regex_filter" typescript:",notnull"`
|
||||
GroupField clibase.String `json:"groups_field" typescript:",notnull"`
|
||||
GroupMapping clibase.Struct[map[string]string] `json:"group_mapping" typescript:",notnull"`
|
||||
UserRoleField clibase.String `json:"user_role_field" typescript:",notnull"`
|
||||
|
@ -1066,6 +1068,26 @@ when required by your organization's security policy.`,
|
|||
Group: &deploymentGroupOIDC,
|
||||
YAML: "groupMapping",
|
||||
},
|
||||
{
|
||||
Name: "Enable OIDC Group Auto Create",
|
||||
Description: "Automatically creates missing groups from a user's groups claim.",
|
||||
Flag: "oidc-group-auto-create",
|
||||
Env: "CODER_OIDC_GROUP_AUTO_CREATE",
|
||||
Default: "false",
|
||||
Value: &c.OIDC.GroupAutoCreate,
|
||||
Group: &deploymentGroupOIDC,
|
||||
YAML: "enableGroupAutoCreate",
|
||||
},
|
||||
{
|
||||
Name: "OIDC Regex Group Filter",
|
||||
Description: "If provided any group name not matching the regex is ignored. This allows for filtering out groups that are not needed. This filter is applied after the group mapping.",
|
||||
Flag: "oidc-group-regex-filter",
|
||||
Env: "CODER_OIDC_GROUP_REGEX_FILTER",
|
||||
Default: ".*",
|
||||
Value: &c.OIDC.GroupRegexFilter,
|
||||
Group: &deploymentGroupOIDC,
|
||||
YAML: "groupRegexFilter",
|
||||
},
|
||||
{
|
||||
Name: "OIDC User Role Field",
|
||||
Description: "This field must be set if using the user roles sync feature. Set this to the name of the claim used to store the user's role. The roles should be sent as an array of strings.",
|
||||
|
|
|
@ -10,6 +10,13 @@ import (
|
|||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
type GroupSource string
|
||||
|
||||
const (
|
||||
GroupSourceUser GroupSource = "user"
|
||||
GroupSourceOIDC GroupSource = "oidc"
|
||||
)
|
||||
|
||||
type CreateGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
|
@ -18,13 +25,14 @@ type CreateGroupRequest struct {
|
|||
}
|
||||
|
||||
type Group struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
|
||||
Members []User `json:"members"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
QuotaAllowance int `json:"quota_allowance"`
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
|
||||
Members []User `json:"members"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
QuotaAllowance int `json:"quota_allowance"`
|
||||
Source GroupSource `json:"source"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateGroup(ctx context.Context, orgID uuid.UUID, req CreateGroupRequest) (Group, error) {
|
||||
|
|
|
@ -13,7 +13,7 @@ We track the following resources:
|
|||
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| APIKey<br><i>login, logout, register, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| AuditOAuthConvertState<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr></tbody></table> |
|
||||
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr><tr><td>source</td><td>false</td></tr></tbody></table> |
|
||||
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
|
||||
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>active_version_id</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>inactivity_ttl</td><td>true</td></tr><tr><td>locked_ttl</td><td>true</td></tr><tr><td>max_ttl</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>restart_requirement_days_of_week</td><td>true</td></tr><tr><td>restart_requirement_weeks</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
|
||||
|
|
|
@ -197,7 +197,8 @@ curl -X GET http://coder-server:8080/api/v2/groups/{group} \
|
|||
],
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"quota_allowance": 0
|
||||
"quota_allowance": 0,
|
||||
"source": "user"
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -258,7 +259,8 @@ curl -X DELETE http://coder-server:8080/api/v2/groups/{group} \
|
|||
],
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"quota_allowance": 0
|
||||
"quota_allowance": 0,
|
||||
"source": "user"
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -319,7 +321,8 @@ curl -X PATCH http://coder-server:8080/api/v2/groups/{group} \
|
|||
],
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"quota_allowance": 0
|
||||
"quota_allowance": 0,
|
||||
"source": "user"
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -455,7 +458,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups
|
|||
],
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"quota_allowance": 0
|
||||
"quota_allowance": 0,
|
||||
"source": "user"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
@ -470,28 +474,29 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups
|
|||
|
||||
Status Code **200**
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| --------------------- | ---------------------------------------------------- | -------- | ------------ | ----------- |
|
||||
| `[array item]` | array | false | | |
|
||||
| `» avatar_url` | string | false | | |
|
||||
| `» display_name` | string | false | | |
|
||||
| `» id` | string(uuid) | false | | |
|
||||
| `» members` | array | false | | |
|
||||
| `»» avatar_url` | string(uri) | false | | |
|
||||
| `»» created_at` | string(date-time) | true | | |
|
||||
| `»» email` | string(email) | true | | |
|
||||
| `»» id` | string(uuid) | true | | |
|
||||
| `»» last_seen_at` | string(date-time) | false | | |
|
||||
| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | |
|
||||
| `»» organization_ids` | array | false | | |
|
||||
| `»» roles` | array | false | | |
|
||||
| `»»» display_name` | string | false | | |
|
||||
| `»»» name` | string | false | | |
|
||||
| `»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | |
|
||||
| `»» username` | string | true | | |
|
||||
| `» name` | string | false | | |
|
||||
| `» organization_id` | string(uuid) | false | | |
|
||||
| `» quota_allowance` | integer | false | | |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| --------------------- | ------------------------------------------------------ | -------- | ------------ | ----------- |
|
||||
| `[array item]` | array | false | | |
|
||||
| `» avatar_url` | string | false | | |
|
||||
| `» display_name` | string | false | | |
|
||||
| `» id` | string(uuid) | false | | |
|
||||
| `» members` | array | false | | |
|
||||
| `»» avatar_url` | string(uri) | false | | |
|
||||
| `»» created_at` | string(date-time) | true | | |
|
||||
| `»» email` | string(email) | true | | |
|
||||
| `»» id` | string(uuid) | true | | |
|
||||
| `»» last_seen_at` | string(date-time) | false | | |
|
||||
| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | |
|
||||
| `»» organization_ids` | array | false | | |
|
||||
| `»» roles` | array | false | | |
|
||||
| `»»» display_name` | string | false | | |
|
||||
| `»»» name` | string | false | | |
|
||||
| `»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | |
|
||||
| `»» username` | string | true | | |
|
||||
| `» name` | string | false | | |
|
||||
| `» organization_id` | string(uuid) | false | | |
|
||||
| `» quota_allowance` | integer | false | | |
|
||||
| `» source` | [codersdk.GroupSource](schemas.md#codersdkgroupsource) | false | | |
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
|
@ -504,6 +509,8 @@ Status Code **200**
|
|||
| `login_type` | `none` |
|
||||
| `status` | `active` |
|
||||
| `status` | `suspended` |
|
||||
| `source` | `user` |
|
||||
| `source` | `oidc` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
|
@ -569,7 +576,8 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/groups
|
|||
],
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"quota_allowance": 0
|
||||
"quota_allowance": 0,
|
||||
"source": "user"
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -631,7 +639,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups/
|
|||
],
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"quota_allowance": 0
|
||||
"quota_allowance": 0,
|
||||
"source": "user"
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -1202,7 +1211,8 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \
|
|||
],
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"quota_allowance": 0
|
||||
"quota_allowance": 0,
|
||||
"source": "user"
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
|
@ -1238,30 +1248,31 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \
|
|||
|
||||
Status Code **200**
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ---------------------- | ---------------------------------------------------- | -------- | ------------ | ----------- |
|
||||
| `[array item]` | array | false | | |
|
||||
| `» groups` | array | false | | |
|
||||
| `»» avatar_url` | string | false | | |
|
||||
| `»» display_name` | string | false | | |
|
||||
| `»» id` | string(uuid) | false | | |
|
||||
| `»» members` | array | false | | |
|
||||
| `»»» avatar_url` | string(uri) | false | | |
|
||||
| `»»» created_at` | string(date-time) | true | | |
|
||||
| `»»» email` | string(email) | true | | |
|
||||
| `»»» id` | string(uuid) | true | | |
|
||||
| `»»» last_seen_at` | string(date-time) | false | | |
|
||||
| `»»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | |
|
||||
| `»»» organization_ids` | array | false | | |
|
||||
| `»»» roles` | array | false | | |
|
||||
| `»»»» display_name` | string | false | | |
|
||||
| `»»»» name` | string | false | | |
|
||||
| `»»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | |
|
||||
| `»»» username` | string | true | | |
|
||||
| `»» name` | string | false | | |
|
||||
| `»» organization_id` | string(uuid) | false | | |
|
||||
| `»» quota_allowance` | integer | false | | |
|
||||
| `» users` | array | false | | |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ---------------------- | ------------------------------------------------------ | -------- | ------------ | ----------- |
|
||||
| `[array item]` | array | false | | |
|
||||
| `» groups` | array | false | | |
|
||||
| `»» avatar_url` | string | false | | |
|
||||
| `»» display_name` | string | false | | |
|
||||
| `»» id` | string(uuid) | false | | |
|
||||
| `»» members` | array | false | | |
|
||||
| `»»» avatar_url` | string(uri) | false | | |
|
||||
| `»»» created_at` | string(date-time) | true | | |
|
||||
| `»»» email` | string(email) | true | | |
|
||||
| `»»» id` | string(uuid) | true | | |
|
||||
| `»»» last_seen_at` | string(date-time) | false | | |
|
||||
| `»»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | |
|
||||
| `»»» organization_ids` | array | false | | |
|
||||
| `»»» roles` | array | false | | |
|
||||
| `»»»» display_name` | string | false | | |
|
||||
| `»»»» name` | string | false | | |
|
||||
| `»»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | |
|
||||
| `»»» username` | string | true | | |
|
||||
| `»» name` | string | false | | |
|
||||
| `»» organization_id` | string(uuid) | false | | |
|
||||
| `»» quota_allowance` | integer | false | | |
|
||||
| `»» source` | [codersdk.GroupSource](schemas.md#codersdkgroupsource) | false | | |
|
||||
| `» users` | array | false | | |
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
|
@ -1274,6 +1285,8 @@ Status Code **200**
|
|||
| `login_type` | `none` |
|
||||
| `status` | `active` |
|
||||
| `status` | `suspended` |
|
||||
| `source` | `user` |
|
||||
| `source` | `oidc` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
|
|
|
@ -260,7 +260,9 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
|
|||
"client_secret": "string",
|
||||
"email_domain": ["string"],
|
||||
"email_field": "string",
|
||||
"group_auto_create": true,
|
||||
"group_mapping": {},
|
||||
"group_regex_filter": {},
|
||||
"groups_field": "string",
|
||||
"icon_url": {
|
||||
"forceQuery": true,
|
||||
|
|
|
@ -595,6 +595,16 @@
|
|||
| `value_source` | [clibase.ValueSource](#clibasevaluesource) | false | | |
|
||||
| `yaml` | string | false | | Yaml is the YAML key used to configure this option. If unset, YAML configuring is disabled. |
|
||||
|
||||
## clibase.Regexp
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
_None_
|
||||
|
||||
## clibase.Struct-array_codersdk_GitAuthConfig
|
||||
|
||||
```json
|
||||
|
@ -788,7 +798,8 @@
|
|||
],
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"quota_allowance": 0
|
||||
"quota_allowance": 0,
|
||||
"source": "user"
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
|
@ -2054,7 +2065,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
|||
"client_secret": "string",
|
||||
"email_domain": ["string"],
|
||||
"email_field": "string",
|
||||
"group_auto_create": true,
|
||||
"group_mapping": {},
|
||||
"group_regex_filter": {},
|
||||
"groups_field": "string",
|
||||
"icon_url": {
|
||||
"forceQuery": true,
|
||||
|
@ -2412,7 +2425,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
|||
"client_secret": "string",
|
||||
"email_domain": ["string"],
|
||||
"email_field": "string",
|
||||
"group_auto_create": true,
|
||||
"group_mapping": {},
|
||||
"group_regex_filter": {},
|
||||
"groups_field": "string",
|
||||
"icon_url": {
|
||||
"forceQuery": true,
|
||||
|
@ -2959,21 +2974,38 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
|||
],
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"quota_allowance": 0
|
||||
"quota_allowance": 0,
|
||||
"source": "user"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ----------------- | --------------------------------------- | -------- | ------------ | ----------- |
|
||||
| `avatar_url` | string | false | | |
|
||||
| `display_name` | string | false | | |
|
||||
| `id` | string | false | | |
|
||||
| `members` | array of [codersdk.User](#codersdkuser) | false | | |
|
||||
| `name` | string | false | | |
|
||||
| `organization_id` | string | false | | |
|
||||
| `quota_allowance` | integer | false | | |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ----------------- | -------------------------------------------- | -------- | ------------ | ----------- |
|
||||
| `avatar_url` | string | false | | |
|
||||
| `display_name` | string | false | | |
|
||||
| `id` | string | false | | |
|
||||
| `members` | array of [codersdk.User](#codersdkuser) | false | | |
|
||||
| `name` | string | false | | |
|
||||
| `organization_id` | string | false | | |
|
||||
| `quota_allowance` | integer | false | | |
|
||||
| `source` | [codersdk.GroupSource](#codersdkgroupsource) | false | | |
|
||||
|
||||
## codersdk.GroupSource
|
||||
|
||||
```json
|
||||
"user"
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Value |
|
||||
| ------ |
|
||||
| `user` |
|
||||
| `oidc` |
|
||||
|
||||
## codersdk.Healthcheck
|
||||
|
||||
|
@ -3305,7 +3337,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
|||
"client_secret": "string",
|
||||
"email_domain": ["string"],
|
||||
"email_field": "string",
|
||||
"group_auto_create": true,
|
||||
"group_mapping": {},
|
||||
"group_regex_filter": {},
|
||||
"groups_field": "string",
|
||||
"icon_url": {
|
||||
"forceQuery": true,
|
||||
|
@ -3334,26 +3368,28 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
|||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ----------------------- | -------------------------- | -------- | ------------ | ----------- |
|
||||
| `allow_signups` | boolean | false | | |
|
||||
| `auth_url_params` | object | false | | |
|
||||
| `client_id` | string | false | | |
|
||||
| `client_secret` | string | false | | |
|
||||
| `email_domain` | array of string | false | | |
|
||||
| `email_field` | string | false | | |
|
||||
| `group_mapping` | object | false | | |
|
||||
| `groups_field` | string | false | | |
|
||||
| `icon_url` | [clibase.URL](#clibaseurl) | false | | |
|
||||
| `ignore_email_verified` | boolean | false | | |
|
||||
| `ignore_user_info` | boolean | false | | |
|
||||
| `issuer_url` | string | false | | |
|
||||
| `scopes` | array of string | false | | |
|
||||
| `sign_in_text` | string | false | | |
|
||||
| `user_role_field` | string | false | | |
|
||||
| `user_role_mapping` | object | false | | |
|
||||
| `user_roles_default` | array of string | false | | |
|
||||
| `username_field` | string | false | | |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ----------------------- | -------------------------------- | -------- | ------------ | ----------- |
|
||||
| `allow_signups` | boolean | false | | |
|
||||
| `auth_url_params` | object | false | | |
|
||||
| `client_id` | string | false | | |
|
||||
| `client_secret` | string | false | | |
|
||||
| `email_domain` | array of string | false | | |
|
||||
| `email_field` | string | false | | |
|
||||
| `group_auto_create` | boolean | false | | |
|
||||
| `group_mapping` | object | false | | |
|
||||
| `group_regex_filter` | [clibase.Regexp](#clibaseregexp) | false | | |
|
||||
| `groups_field` | string | false | | |
|
||||
| `icon_url` | [clibase.URL](#clibaseurl) | false | | |
|
||||
| `ignore_email_verified` | boolean | false | | |
|
||||
| `ignore_user_info` | boolean | false | | |
|
||||
| `issuer_url` | string | false | | |
|
||||
| `scopes` | array of string | false | | |
|
||||
| `sign_in_text` | string | false | | |
|
||||
| `user_role_field` | string | false | | |
|
||||
| `user_role_mapping` | object | false | | |
|
||||
| `user_roles_default` | array of string | false | | |
|
||||
| `username_field` | string | false | | |
|
||||
|
||||
## codersdk.Organization
|
||||
|
||||
|
|
|
@ -243,6 +243,17 @@ Disable automatic session expiry bumping due to activity. This forces all sessio
|
|||
|
||||
Specifies the custom docs URL.
|
||||
|
||||
### --oidc-group-auto-create
|
||||
|
||||
| | |
|
||||
| ----------- | ------------------------------------------ |
|
||||
| Type | <code>bool</code> |
|
||||
| Environment | <code>$CODER_OIDC_GROUP_AUTO_CREATE</code> |
|
||||
| YAML | <code>oidc.enableGroupAutoCreate</code> |
|
||||
| Default | <code>false</code> |
|
||||
|
||||
Automatically creates missing groups from a user's groups claim.
|
||||
|
||||
### --enable-terraform-debug-mode
|
||||
|
||||
| | |
|
||||
|
@ -521,6 +532,17 @@ Ignore the userinfo endpoint and only use the ID token for user information.
|
|||
|
||||
Issuer URL to use for Login with OIDC.
|
||||
|
||||
### --oidc-group-regex-filter
|
||||
|
||||
| | |
|
||||
| ----------- | ------------------------------------------- |
|
||||
| Type | <code>regexp</code> |
|
||||
| Environment | <code>$CODER_OIDC_GROUP_REGEX_FILTER</code> |
|
||||
| YAML | <code>oidc.groupRegexFilter</code> |
|
||||
| Default | <code>.\*</code> |
|
||||
|
||||
If provided any group name not matching the regex is ignored. This allows for filtering out groups that are not needed. This filter is applied after the group mapping.
|
||||
|
||||
### --oidc-scopes
|
||||
|
||||
| | |
|
||||
|
|
|
@ -156,6 +156,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
|
|||
"avatar_url": ActionTrack,
|
||||
"quota_allowance": ActionTrack,
|
||||
"members": ActionTrack,
|
||||
"source": ActionIgnore,
|
||||
},
|
||||
&database.APIKey{}: {
|
||||
"id": ActionIgnore,
|
||||
|
|
|
@ -298,6 +298,9 @@ can safely ignore these settings.
|
|||
GitHub.
|
||||
|
||||
[1mOIDC Options[0m
|
||||
--oidc-group-auto-create bool, $CODER_OIDC_GROUP_AUTO_CREATE (default: false)
|
||||
Automatically creates missing groups from a user's groups claim.
|
||||
|
||||
--oidc-allow-signups bool, $CODER_OIDC_ALLOW_SIGNUPS (default: true)
|
||||
Whether new users can sign up with OIDC.
|
||||
|
||||
|
@ -334,6 +337,11 @@ can safely ignore these settings.
|
|||
--oidc-issuer-url string, $CODER_OIDC_ISSUER_URL
|
||||
Issuer URL to use for Login with OIDC.
|
||||
|
||||
--oidc-group-regex-filter regexp, $CODER_OIDC_GROUP_REGEX_FILTER (default: .*)
|
||||
If provided any group name not matching the regex is ignored. This
|
||||
allows for filtering out groups that are not needed. This filter is
|
||||
applied after the group mapping.
|
||||
|
||||
--oidc-scopes string-array, $CODER_OIDC_SCOPES (default: openid,profile,email)
|
||||
Scopes to grant when authenticating with OIDC.
|
||||
|
||||
|
|
|
@ -409,6 +409,7 @@ func convertGroup(g database.Group, users []database.User) codersdk.Group {
|
|||
AvatarURL: g.AvatarURL,
|
||||
QuotaAllowance: int(g.QuotaAllowance),
|
||||
Members: convertUsers(users, orgs),
|
||||
Source: codersdk.GroupSource(g.Source),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,10 +9,12 @@ import (
|
|||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func (api *API) setUserGroups(ctx context.Context, db database.Store, userID uuid.UUID, groupNames []string) error {
|
||||
// nolint: revive
|
||||
func (api *API) setUserGroups(ctx context.Context, logger slog.Logger, db database.Store, userID uuid.UUID, groupNames []string, createMissingGroups bool) error {
|
||||
api.entitlementsMu.RLock()
|
||||
enabled := api.entitlements.Features[codersdk.FeatureTemplateRBAC].Enabled
|
||||
api.entitlementsMu.RUnlock()
|
||||
|
@ -39,6 +41,25 @@ func (api *API) setUserGroups(ctx context.Context, db database.Store, userID uui
|
|||
return xerrors.Errorf("delete user groups: %w", err)
|
||||
}
|
||||
|
||||
if createMissingGroups {
|
||||
// This is the system creating these additional groups, so we use the system restricted context.
|
||||
// nolint:gocritic
|
||||
created, err := tx.InsertMissingGroups(dbauthz.AsSystemRestricted(ctx), database.InsertMissingGroupsParams{
|
||||
OrganizationID: orgs[0].ID,
|
||||
GroupNames: groupNames,
|
||||
Source: database.GroupSourceOidc,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert missing groups: %w", err)
|
||||
}
|
||||
if len(created) > 0 {
|
||||
logger.Debug(ctx, "auto created missing groups",
|
||||
slog.F("org_id", orgs[0].ID),
|
||||
slog.F("created", created),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-add the user to all groups returned by the auth provider.
|
||||
err = tx.InsertUserGroupsByName(ctx, database.InsertUserGroupsByNameParams{
|
||||
UserID: userID,
|
||||
|
@ -53,13 +74,13 @@ func (api *API) setUserGroups(ctx context.Context, db database.Store, userID uui
|
|||
}, nil)
|
||||
}
|
||||
|
||||
func (api *API) setUserSiteRoles(ctx context.Context, db database.Store, userID uuid.UUID, roles []string) error {
|
||||
func (api *API) setUserSiteRoles(ctx context.Context, logger slog.Logger, db database.Store, userID uuid.UUID, roles []string) error {
|
||||
api.entitlementsMu.RLock()
|
||||
enabled := api.entitlements.Features[codersdk.FeatureUserRoleManagement].Enabled
|
||||
api.entitlementsMu.RUnlock()
|
||||
|
||||
if !enabled {
|
||||
api.Logger.Warn(ctx, "attempted to assign OIDC user roles without enterprise entitlement, roles left unchanged",
|
||||
logger.Warn(ctx, "attempted to assign OIDC user roles without enterprise entitlement, roles left unchanged",
|
||||
slog.F("user_id", userID), slog.F("roles", roles),
|
||||
)
|
||||
return nil
|
||||
|
|
|
@ -5,10 +5,9 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/coder/coder/enterprise/coderd/license"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
@ -16,9 +15,13 @@ import (
|
|||
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/util/slice"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/enterprise/coderd/coderdenttest"
|
||||
"github.com/coder/coder/enterprise/coderd/license"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
|
@ -354,6 +357,213 @@ func TestUserOIDC(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestGroupSync(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
modCfg func(cfg *coderd.OIDCConfig)
|
||||
// initialOrgGroups is initial groups in the org
|
||||
initialOrgGroups []string
|
||||
// initialUserGroups is initial groups for the user
|
||||
initialUserGroups []string
|
||||
// expectedUserGroups is expected groups for the user
|
||||
expectedUserGroups []string
|
||||
// expectedOrgGroups is expected all groups on the system
|
||||
expectedOrgGroups []string
|
||||
claims jwt.MapClaims
|
||||
}{
|
||||
{
|
||||
name: "NoGroups",
|
||||
modCfg: func(cfg *coderd.OIDCConfig) {
|
||||
},
|
||||
initialOrgGroups: []string{},
|
||||
expectedUserGroups: []string{},
|
||||
expectedOrgGroups: []string{},
|
||||
claims: jwt.MapClaims{},
|
||||
},
|
||||
{
|
||||
name: "GroupSyncDisabled",
|
||||
modCfg: func(cfg *coderd.OIDCConfig) {
|
||||
// Disable group sync
|
||||
cfg.GroupField = ""
|
||||
cfg.GroupFilter = regexp.MustCompile(".*")
|
||||
},
|
||||
initialOrgGroups: []string{"a", "b", "c", "d"},
|
||||
initialUserGroups: []string{"b", "c", "d"},
|
||||
expectedUserGroups: []string{"b", "c", "d"},
|
||||
expectedOrgGroups: []string{"a", "b", "c", "d"},
|
||||
claims: jwt.MapClaims{},
|
||||
},
|
||||
{
|
||||
// From a,c,b -> b,c,d
|
||||
name: "ChangeUserGroups",
|
||||
modCfg: func(cfg *coderd.OIDCConfig) {
|
||||
cfg.GroupMapping = map[string]string{
|
||||
"D": "d",
|
||||
}
|
||||
},
|
||||
initialOrgGroups: []string{"a", "b", "c", "d"},
|
||||
initialUserGroups: []string{"a", "b", "c"},
|
||||
expectedUserGroups: []string{"b", "c", "d"},
|
||||
expectedOrgGroups: []string{"a", "b", "c", "d"},
|
||||
claims: jwt.MapClaims{
|
||||
// D -> d mapped
|
||||
"groups": []string{"b", "c", "D"},
|
||||
},
|
||||
},
|
||||
{
|
||||
// From a,c,b -> []
|
||||
name: "RemoveAllGroups",
|
||||
modCfg: func(cfg *coderd.OIDCConfig) {
|
||||
cfg.GroupFilter = regexp.MustCompile(".*")
|
||||
},
|
||||
initialOrgGroups: []string{"a", "b", "c", "d"},
|
||||
initialUserGroups: []string{"a", "b", "c"},
|
||||
expectedUserGroups: []string{},
|
||||
expectedOrgGroups: []string{"a", "b", "c", "d"},
|
||||
claims: jwt.MapClaims{
|
||||
// No claim == no groups
|
||||
},
|
||||
},
|
||||
{
|
||||
// From a,c,b -> b,c,d,e,f
|
||||
name: "CreateMissingGroups",
|
||||
modCfg: func(cfg *coderd.OIDCConfig) {
|
||||
cfg.CreateMissingGroups = true
|
||||
},
|
||||
initialOrgGroups: []string{"a", "b", "c", "d"},
|
||||
initialUserGroups: []string{"a", "b", "c"},
|
||||
expectedUserGroups: []string{"b", "c", "d", "e", "f"},
|
||||
expectedOrgGroups: []string{"a", "b", "c", "d", "e", "f"},
|
||||
claims: jwt.MapClaims{
|
||||
"groups": []string{"b", "c", "d", "e", "f"},
|
||||
},
|
||||
},
|
||||
{
|
||||
// From a,c,b -> b,c,d,e,f
|
||||
name: "CreateMissingGroupsFilter",
|
||||
modCfg: func(cfg *coderd.OIDCConfig) {
|
||||
cfg.CreateMissingGroups = true
|
||||
// Only single letter groups
|
||||
cfg.GroupFilter = regexp.MustCompile("^[a-z]$")
|
||||
cfg.GroupMapping = map[string]string{
|
||||
// Does not match the filter, but does after being mapped!
|
||||
"zebra": "z",
|
||||
}
|
||||
},
|
||||
initialOrgGroups: []string{"a", "b", "c", "d"},
|
||||
initialUserGroups: []string{"a", "b", "c"},
|
||||
expectedUserGroups: []string{"b", "c", "d", "e", "f", "z"},
|
||||
expectedOrgGroups: []string{"a", "b", "c", "d", "e", "f", "z"},
|
||||
claims: jwt.MapClaims{
|
||||
"groups": []string{
|
||||
"b", "c", "d", "e", "f",
|
||||
// These groups are ignored
|
||||
"excess", "ignore", "dumb", "foobar", "zebra",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
conf := coderdtest.NewOIDCConfig(t, "")
|
||||
|
||||
config := conf.OIDCConfig(t, jwt.MapClaims{}, tc.modCfg)
|
||||
|
||||
client, _, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
OIDCConfig: config,
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{codersdk.FeatureTemplateRBAC: 1},
|
||||
},
|
||||
})
|
||||
|
||||
admin, err := client.User(ctx, "me")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, admin.OrganizationIDs, 1)
|
||||
|
||||
// Setup
|
||||
initialGroups := make(map[string]codersdk.Group)
|
||||
for _, group := range tc.initialOrgGroups {
|
||||
newGroup, err := client.CreateGroup(ctx, admin.OrganizationIDs[0], codersdk.CreateGroupRequest{
|
||||
Name: group,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, newGroup.Members, 0)
|
||||
initialGroups[group] = newGroup
|
||||
}
|
||||
|
||||
// Create the user and add them to their initial groups
|
||||
_, user := coderdtest.CreateAnotherUser(t, client, admin.OrganizationIDs[0])
|
||||
for _, group := range tc.initialUserGroups {
|
||||
_, err := client.PatchGroup(ctx, initialGroups[group].ID, codersdk.PatchGroupRequest{
|
||||
AddUsers: []string{user.ID.String()},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// nolint:gocritic
|
||||
_, err = api.Database.UpdateUserLoginType(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLoginTypeParams{
|
||||
NewLoginType: database.LoginTypeOIDC,
|
||||
UserID: user.ID,
|
||||
})
|
||||
require.NoError(t, err, "user must be oidc type")
|
||||
|
||||
// Log in the new user
|
||||
tc.claims["email"] = user.Email
|
||||
resp := oidcCallback(t, client, conf.EncodeClaims(t, tc.claims))
|
||||
assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
orgGroups, err := client.GroupsByOrganization(ctx, admin.OrganizationIDs[0])
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, group := range orgGroups {
|
||||
if slice.Contains(tc.initialOrgGroups, group.Name) {
|
||||
require.Equal(t, group.Source, codersdk.GroupSourceUser)
|
||||
} else {
|
||||
require.Equal(t, group.Source, codersdk.GroupSourceOIDC)
|
||||
}
|
||||
}
|
||||
|
||||
orgGroupsMap := make(map[string]struct{})
|
||||
for _, group := range orgGroups {
|
||||
orgGroupsMap[group.Name] = struct{}{}
|
||||
}
|
||||
|
||||
for _, expected := range tc.expectedOrgGroups {
|
||||
if _, ok := orgGroupsMap[expected]; !ok {
|
||||
t.Errorf("expected group %s not found", expected)
|
||||
}
|
||||
delete(orgGroupsMap, expected)
|
||||
}
|
||||
require.Empty(t, orgGroupsMap, "unexpected groups found")
|
||||
|
||||
expectedUserGroups := make(map[string]struct{})
|
||||
for _, group := range tc.expectedUserGroups {
|
||||
expectedUserGroups[group] = struct{}{}
|
||||
}
|
||||
|
||||
for _, group := range orgGroups {
|
||||
userInGroup := slice.ContainsCompare(group.Members, codersdk.User{Email: user.Email}, func(a, b codersdk.User) bool {
|
||||
return a.Email == b.Email
|
||||
})
|
||||
if _, ok := expectedUserGroups[group.Name]; ok {
|
||||
require.Truef(t, userInGroup, "user should be in group %s", group.Name)
|
||||
} else {
|
||||
require.Falsef(t, userInGroup, "user should not be in group %s", group.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func oidcCallback(t *testing.T, client *codersdk.Client, code string) *http.Response {
|
||||
t.Helper()
|
||||
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
|
|
|
@ -513,6 +513,7 @@ export interface Group {
|
|||
readonly members: User[]
|
||||
readonly avatar_url: string
|
||||
readonly quota_allowance: number
|
||||
readonly source: GroupSource
|
||||
}
|
||||
|
||||
// From codersdk/workspaceapps.go
|
||||
|
@ -626,6 +627,10 @@ export interface OIDCConfig {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type
|
||||
readonly auth_url_params: any
|
||||
readonly ignore_user_info: boolean
|
||||
readonly group_auto_create: boolean
|
||||
// Named type "github.com/coder/coder/cli/clibase.Regexp" unknown, using "any"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type
|
||||
readonly group_regex_filter: any
|
||||
readonly groups_field: string
|
||||
// Named type "github.com/coder/coder/cli/clibase.Struct[map[string]string]" unknown, using "any"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type
|
||||
|
@ -1639,6 +1644,10 @@ export const GitProviders: GitProvider[] = [
|
|||
"gitlab",
|
||||
]
|
||||
|
||||
// From codersdk/groups.go
|
||||
export type GroupSource = "oidc" | "user"
|
||||
export const GroupSources: GroupSource[] = ["oidc", "user"]
|
||||
|
||||
// From codersdk/insights.go
|
||||
export type InsightsReportInterval = "day"
|
||||
export const InsightsReportIntervals: InsightsReportInterval[] = ["day"]
|
||||
|
|
|
@ -1670,6 +1670,7 @@ export const MockGroup: TypesGen.Group = {
|
|||
organization_id: MockOrganization.id,
|
||||
members: [MockUser, MockUser2],
|
||||
quota_allowance: 5,
|
||||
source: "user",
|
||||
}
|
||||
|
||||
export const MockTemplateACL: TypesGen.TemplateACL = {
|
||||
|
|
|
@ -8,6 +8,7 @@ export const everyOneGroup = (organizationId: string): Group => ({
|
|||
members: [],
|
||||
avatar_url: "",
|
||||
quota_allowance: 0,
|
||||
source: "user",
|
||||
})
|
||||
|
||||
export const getGroupSubtitle = (group: Group): string => {
|
||||
|
|
Loading…
Reference in New Issue