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:
Steven Masley 2023-08-08 11:37:49 -05:00 committed by GitHub
parent 4a987e9917
commit f4122fa9f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 887 additions and 128 deletions

View File

@ -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: &regexpString,
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: &regexpString,
Flag: "regexp-string",
},
}
err := os.FlagSet().Parse([]string{"--regexp-string", "(("})
require.Error(t, err)
})
}
func TestOptionSet_ParseEnv(t *testing.T) {

View File

@ -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

View File

@ -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,

View File

@ -298,6 +298,9 @@ can safely ignore these settings.
GitHub.
OIDC Options
--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.

View File

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

23
coderd/apidoc/docs.go generated
View File

@ -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"
},

View File

@ -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"
},

View File

@ -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

View File

@ -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)
}

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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,

View File

@ -0,0 +1,8 @@
BEGIN;
ALTER TABLE groups
DROP COLUMN source;
DROP TYPE group_source;
COMMIT;

View File

@ -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;

View File

@ -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 {

View File

@ -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)

View File

@ -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
}

View File

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

View File

@ -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,

View File

@ -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.",

View File

@ -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) {

View File

@ -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> |

119
docs/api/enterprise.md generated
View File

@ -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).

2
docs/api/general.md generated
View File

@ -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,

98
docs/api/schemas.md generated
View File

@ -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

22
docs/cli/server.md generated
View File

@ -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
| | |

View File

@ -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,

View File

@ -298,6 +298,9 @@ can safely ignore these settings.
GitHub.
OIDC Options
--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.

View File

@ -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),
}
}

View File

@ -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

View File

@ -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 {

View File

@ -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"]

View File

@ -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 = {

View File

@ -8,6 +8,7 @@ export const everyOneGroup = (organizationId: string): Group => ({
members: [],
avatar_url: "",
quota_allowance: 0,
source: "user",
})
export const getGroupSubtitle = (group: Group): string => {