mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: make service accounts a Premium feature (#24020)
This commit is contained in:
@@ -134,6 +134,7 @@ func TestUserCreate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "ServiceAccount",
|
name: "ServiceAccount",
|
||||||
args: []string{"--service-account", "-u", "dean"},
|
args: []string{"--service-account", "-u", "dean"},
|
||||||
|
err: "Premium feature",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ServiceAccountLoginType",
|
name: "ServiceAccountLoginType",
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ func UsersPagination(
|
|||||||
require.Contains(t, gotUsers[0].Name, "after")
|
require.Contains(t, gotUsers[0].Name, "after")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UsersFilterOptions struct {
|
||||||
|
CreateServiceAccounts bool
|
||||||
|
}
|
||||||
|
|
||||||
// UsersFilter creates a set of users to run various filters against for
|
// UsersFilter creates a set of users to run various filters against for
|
||||||
// testing. It can be used to test filtering both users and group members.
|
// testing. It can be used to test filtering both users and group members.
|
||||||
func UsersFilter(
|
func UsersFilter(
|
||||||
@@ -130,11 +134,16 @@ func UsersFilter(
|
|||||||
t *testing.T,
|
t *testing.T,
|
||||||
client *codersdk.Client,
|
client *codersdk.Client,
|
||||||
db database.Store,
|
db database.Store,
|
||||||
|
options *UsersFilterOptions,
|
||||||
setup func(users []codersdk.User),
|
setup func(users []codersdk.User),
|
||||||
fetch func(ctx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser,
|
fetch func(ctx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
if options == nil {
|
||||||
|
options = &UsersFilterOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
firstUser, err := client.User(setupCtx, codersdk.Me)
|
firstUser, err := client.User(setupCtx, codersdk.Me)
|
||||||
require.NoError(t, err, "fetch me")
|
require.NoError(t, err, "fetch me")
|
||||||
|
|
||||||
@@ -211,11 +220,13 @@ func UsersFilter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add some service accounts.
|
// Add some service accounts.
|
||||||
for range 3 {
|
if options.CreateServiceAccounts {
|
||||||
_, user := CreateAnotherUserMutators(t, client, orgID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
|
for range 3 {
|
||||||
r.ServiceAccount = true
|
_, user := CreateAnotherUserMutators(t, client, orgID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
|
||||||
})
|
r.ServiceAccount = true
|
||||||
users = append(users, user)
|
})
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hashedPassword, err := userpassword.Hash("SomeStrongPassword!")
|
hashedPassword, err := userpassword.Hash("SomeStrongPassword!")
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ func TestGetOrgMembersFilter(t *testing.T) {
|
|||||||
setupCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
setupCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
coderdtest.UsersFilter(setupCtx, t, client, api.Database, nil, func(testCtx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser {
|
coderdtest.UsersFilter(setupCtx, t, client, api.Database, nil, nil, func(testCtx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser {
|
||||||
res, err := client.OrganizationMembersPaginated(testCtx, first.OrganizationID, req)
|
res, err := client.OrganizationMembersPaginated(testCtx, first.OrganizationID, req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
reduced := make([]codersdk.ReducedUser, len(res.Members))
|
reduced := make([]codersdk.ReducedUser, len(res.Members))
|
||||||
|
|||||||
@@ -475,6 +475,14 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.UserLoginType = codersdk.LoginTypeNone
|
req.UserLoginType = codersdk.LoginTypeNone
|
||||||
|
|
||||||
|
// Service accounts are a Premium feature.
|
||||||
|
if !api.Entitlements.Enabled(codersdk.FeatureServiceAccounts) {
|
||||||
|
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||||
|
Message: fmt.Sprintf("%s is a Premium feature. Contact sales!", codersdk.FeatureServiceAccounts.Humanize()),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
} else if req.UserLoginType == "" {
|
} else if req.UserLoginType == "" {
|
||||||
// Default to password auth
|
// Default to password auth
|
||||||
req.UserLoginType = codersdk.LoginTypePassword
|
req.UserLoginType = codersdk.LoginTypePassword
|
||||||
|
|||||||
+5
-113
@@ -979,7 +979,7 @@ func TestPostUsers(t *testing.T) {
|
|||||||
require.Equal(t, found.LoginType, codersdk.LoginTypeOIDC)
|
require.Equal(t, found.LoginType, codersdk.LoginTypeOIDC)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ServiceAccount/OK", func(t *testing.T) {
|
t.Run("ServiceAccount/Unlicensed", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
client := coderdtest.New(t, nil)
|
client := coderdtest.New(t, nil)
|
||||||
first := coderdtest.CreateFirstUser(t, client)
|
first := coderdtest.CreateFirstUser(t, client)
|
||||||
@@ -987,98 +987,16 @@ func TestPostUsers(t *testing.T) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
||||||
OrganizationIDs: []uuid.UUID{first.OrganizationID},
|
OrganizationIDs: []uuid.UUID{first.OrganizationID},
|
||||||
Username: "service-acct-ok",
|
Username: "service-acct-ok",
|
||||||
UserLoginType: codersdk.LoginTypeNone,
|
UserLoginType: codersdk.LoginTypeNone,
|
||||||
ServiceAccount: true,
|
ServiceAccount: true,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, codersdk.LoginTypeNone, user.LoginType)
|
|
||||||
require.Empty(t, user.Email)
|
|
||||||
require.Equal(t, "service-acct-ok", user.Username)
|
|
||||||
require.Equal(t, codersdk.UserStatusDormant, user.Status)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ServiceAccount/WithEmail", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
client := coderdtest.New(t, nil)
|
|
||||||
first := coderdtest.CreateFirstUser(t, client)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
|
||||||
OrganizationIDs: []uuid.UUID{first.OrganizationID},
|
|
||||||
Username: "service-acct-email",
|
|
||||||
Email: "should-not-have@email.com",
|
|
||||||
ServiceAccount: true,
|
|
||||||
})
|
|
||||||
var apiErr *codersdk.Error
|
var apiErr *codersdk.Error
|
||||||
require.ErrorAs(t, err, &apiErr)
|
require.ErrorAs(t, err, &apiErr)
|
||||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
|
||||||
require.Contains(t, apiErr.Message, "Email cannot be set for service accounts")
|
require.Contains(t, apiErr.Message, "Premium feature")
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ServiceAccount/WithPassword", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
client := coderdtest.New(t, nil)
|
|
||||||
first := coderdtest.CreateFirstUser(t, client)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
|
||||||
OrganizationIDs: []uuid.UUID{first.OrganizationID},
|
|
||||||
Username: "service-acct-password",
|
|
||||||
Password: "ShouldNotHavePassword123!",
|
|
||||||
ServiceAccount: true,
|
|
||||||
})
|
|
||||||
var apiErr *codersdk.Error
|
|
||||||
require.ErrorAs(t, err, &apiErr)
|
|
||||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
|
||||||
require.Contains(t, apiErr.Message, "Password cannot be set for service accounts")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ServiceAccount/WithInvalidLoginType", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
client := coderdtest.New(t, nil)
|
|
||||||
first := coderdtest.CreateFirstUser(t, client)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
|
||||||
OrganizationIDs: []uuid.UUID{first.OrganizationID},
|
|
||||||
Username: "service-acct-login-type",
|
|
||||||
UserLoginType: codersdk.LoginTypePassword,
|
|
||||||
ServiceAccount: true,
|
|
||||||
})
|
|
||||||
var apiErr *codersdk.Error
|
|
||||||
require.ErrorAs(t, err, &apiErr)
|
|
||||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
|
||||||
require.Contains(t, apiErr.Message, "Service accounts must use login type 'none'")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ServiceAccount/DefaultLoginType", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
client := coderdtest.New(t, nil)
|
|
||||||
first := coderdtest.CreateFirstUser(t, client)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
|
||||||
OrganizationIDs: []uuid.UUID{first.OrganizationID},
|
|
||||||
Username: "service-acct-default-login",
|
|
||||||
ServiceAccount: true,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
found, err := client.User(ctx, user.ID.String())
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, codersdk.LoginTypeNone, found.LoginType)
|
|
||||||
require.Empty(t, found.Email)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("NonServiceAccount/WithoutEmail", func(t *testing.T) {
|
t.Run("NonServiceAccount/WithoutEmail", func(t *testing.T) {
|
||||||
@@ -1098,32 +1016,6 @@ func TestPostUsers(t *testing.T) {
|
|||||||
require.ErrorAs(t, err, &apiErr)
|
require.ErrorAs(t, err, &apiErr)
|
||||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ServiceAccount/MultipleWithoutEmail", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
client := coderdtest.New(t, nil)
|
|
||||||
first := coderdtest.CreateFirstUser(t, client)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
user1, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
|
||||||
OrganizationIDs: []uuid.UUID{first.OrganizationID},
|
|
||||||
Username: "service-acct-multi-1",
|
|
||||||
ServiceAccount: true,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Empty(t, user1.Email)
|
|
||||||
|
|
||||||
user2, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
|
||||||
OrganizationIDs: []uuid.UUID{first.OrganizationID},
|
|
||||||
Username: "service-acct-multi-2",
|
|
||||||
ServiceAccount: true,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Empty(t, user2.Email)
|
|
||||||
require.NotEqual(t, user1.ID, user2.ID)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNotifyCreatedUser(t *testing.T) {
|
func TestNotifyCreatedUser(t *testing.T) {
|
||||||
@@ -1832,7 +1724,7 @@ func TestGetUsersFilter(t *testing.T) {
|
|||||||
setupCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
setupCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
coderdtest.UsersFilter(setupCtx, t, client, api.Database, nil, func(testCtx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser {
|
coderdtest.UsersFilter(setupCtx, t, client, api.Database, nil, nil, func(testCtx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser {
|
||||||
res, err := client.Users(testCtx, req)
|
res, err := client.Users(testCtx, req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
reduced := make([]codersdk.ReducedUser, len(res.Users))
|
reduced := make([]codersdk.ReducedUser, len(res.Users))
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ const (
|
|||||||
FeatureWorkspaceExternalAgent FeatureName = "workspace_external_agent"
|
FeatureWorkspaceExternalAgent FeatureName = "workspace_external_agent"
|
||||||
FeatureAIBridge FeatureName = "aibridge"
|
FeatureAIBridge FeatureName = "aibridge"
|
||||||
FeatureBoundary FeatureName = "boundary"
|
FeatureBoundary FeatureName = "boundary"
|
||||||
|
FeatureServiceAccounts FeatureName = "service_accounts"
|
||||||
FeatureAIGovernanceUserLimit FeatureName = "ai_governance_user_limit"
|
FeatureAIGovernanceUserLimit FeatureName = "ai_governance_user_limit"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -227,6 +228,7 @@ var (
|
|||||||
FeatureWorkspaceExternalAgent,
|
FeatureWorkspaceExternalAgent,
|
||||||
FeatureAIBridge,
|
FeatureAIBridge,
|
||||||
FeatureBoundary,
|
FeatureBoundary,
|
||||||
|
FeatureServiceAccounts,
|
||||||
FeatureAIGovernanceUserLimit,
|
FeatureAIGovernanceUserLimit,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,6 +277,7 @@ func (n FeatureName) AlwaysEnable() bool {
|
|||||||
FeatureWorkspacePrebuilds: true,
|
FeatureWorkspacePrebuilds: true,
|
||||||
FeatureWorkspaceExternalAgent: true,
|
FeatureWorkspaceExternalAgent: true,
|
||||||
FeatureBoundary: true,
|
FeatureBoundary: true,
|
||||||
|
FeatureServiceAccounts: true,
|
||||||
}[n]
|
}[n]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +285,7 @@ func (n FeatureName) AlwaysEnable() bool {
|
|||||||
func (n FeatureName) Enterprise() bool {
|
func (n FeatureName) Enterprise() bool {
|
||||||
switch n {
|
switch n {
|
||||||
// Add all features that should be excluded in the Enterprise feature set.
|
// Add all features that should be excluded in the Enterprise feature set.
|
||||||
case FeatureMultipleOrganizations, FeatureCustomRoles:
|
case FeatureMultipleOrganizations, FeatureCustomRoles, FeatureServiceAccounts:
|
||||||
return false
|
return false
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -1,31 +1,38 @@
|
|||||||
# Headless Authentication
|
# Headless Authentication
|
||||||
|
|
||||||
Headless user accounts that cannot use the web UI to log in to Coder. This is
|
> [!NOTE]
|
||||||
useful for creating accounts for automated systems, such as CI/CD pipelines or
|
> Creating service accounts requires a [Premium license](https://coder.com/pricing).
|
||||||
for users who only consume Coder via another client/API.
|
|
||||||
|
|
||||||
You must have the User Admin role or above to create headless users.
|
Service accounts are headless user accounts that cannot use the web UI to log in
|
||||||
|
to Coder. This is useful for creating accounts for automated systems, such as
|
||||||
|
CI/CD pipelines or for users who only consume Coder via another client/API. Service accounts do not have passwords or associated email addresses.
|
||||||
|
|
||||||
## Create a headless user
|
You must have the User Admin role or above to create service accounts.
|
||||||
|
|
||||||
|
## Create a service account
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
|
Use the `--service-account` flag to create a dedicated service account:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
coder users create \
|
coder users create \
|
||||||
--email="coder-bot@coder.com" \
|
|
||||||
--username="coder-bot" \
|
--username="coder-bot" \
|
||||||
--login-type="none" \
|
--service-account
|
||||||
```
|
```
|
||||||
|
|
||||||
## UI
|
## UI
|
||||||
|
|
||||||
Navigate to the `Users` > `Create user` in the topbar
|
Navigate to **Deployment** > **Users** > **Create user**, then select
|
||||||
|
**Service account** as the login type.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
## Authenticate as a service account
|
||||||
|
|
||||||
To make API or CLI requests on behalf of the headless user, learn how to
|
To make API or CLI requests on behalf of the headless user, learn how to
|
||||||
[generate API tokens on behalf of a user](./sessions-tokens.md#generate-a-long-lived-api-token-on-behalf-of-another-user).
|
[generate API tokens on behalf of a user](./sessions-tokens.md#generate-a-long-lived-api-token-on-behalf-of-another-user).
|
||||||
|
|||||||
+2
-1
@@ -495,7 +495,8 @@
|
|||||||
{
|
{
|
||||||
"title": "Headless Authentication",
|
"title": "Headless Authentication",
|
||||||
"description": "Create and manage headless service accounts for automated systems and API integrations",
|
"description": "Create and manage headless service accounts for automated systems and API integrations",
|
||||||
"path": "./admin/users/headless-auth.md"
|
"path": "./admin/users/headless-auth.md",
|
||||||
|
"state": ["premium"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Groups \u0026 Roles",
|
"title": "Groups \u0026 Roles",
|
||||||
|
|||||||
@@ -1161,7 +1161,8 @@ func TestGetGroupMembersFilter(t *testing.T) {
|
|||||||
},
|
},
|
||||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||||
Features: license.Features{
|
Features: license.Features{
|
||||||
codersdk.FeatureTemplateRBAC: 1,
|
codersdk.FeatureTemplateRBAC: 1,
|
||||||
|
codersdk.FeatureServiceAccounts: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1191,7 +1192,8 @@ func TestGetGroupMembersFilter(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return res.Users
|
return res.Users
|
||||||
}
|
}
|
||||||
coderdtest.UsersFilter(setupCtx, t, client, db, setup, fetch)
|
options := &coderdtest.UsersFilterOptions{CreateServiceAccounts: true}
|
||||||
|
coderdtest.UsersFilter(setupCtx, t, client, db, options, setup, fetch)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetGroupMembersPagination(t *testing.T) {
|
func TestGetGroupMembersPagination(t *testing.T) {
|
||||||
|
|||||||
@@ -614,4 +614,168 @@ func TestEnterprisePostUser(t *testing.T) {
|
|||||||
require.Len(t, memberedOrgs, 2)
|
require.Len(t, memberedOrgs, 2)
|
||||||
require.ElementsMatch(t, []uuid.UUID{second.ID, third.ID}, []uuid.UUID{memberedOrgs[0].ID, memberedOrgs[1].ID})
|
require.ElementsMatch(t, []uuid.UUID{second.ID, third.ID}, []uuid.UUID{memberedOrgs[0].ID, memberedOrgs[1].ID})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("ServiceAccount/OK", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
client, first := coderdenttest.New(t, &coderdenttest.Options{
|
||||||
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||||
|
Features: license.Features{
|
||||||
|
codersdk.FeatureServiceAccounts: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
//nolint:gocritic
|
||||||
|
user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
||||||
|
OrganizationIDs: []uuid.UUID{first.OrganizationID},
|
||||||
|
Username: "service-acct-ok",
|
||||||
|
UserLoginType: codersdk.LoginTypeNone,
|
||||||
|
ServiceAccount: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, codersdk.LoginTypeNone, user.LoginType)
|
||||||
|
require.Empty(t, user.Email)
|
||||||
|
require.Equal(t, "service-acct-ok", user.Username)
|
||||||
|
require.Equal(t, codersdk.UserStatusDormant, user.Status)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ServiceAccount/WithEmail", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
client, first := coderdenttest.New(t, &coderdenttest.Options{
|
||||||
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||||
|
Features: license.Features{
|
||||||
|
codersdk.FeatureServiceAccounts: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
//nolint:gocritic
|
||||||
|
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
||||||
|
OrganizationIDs: []uuid.UUID{first.OrganizationID},
|
||||||
|
Username: "service-acct-email",
|
||||||
|
Email: "should-not-have@email.com",
|
||||||
|
ServiceAccount: true,
|
||||||
|
})
|
||||||
|
var apiErr *codersdk.Error
|
||||||
|
require.ErrorAs(t, err, &apiErr)
|
||||||
|
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||||
|
require.Contains(t, apiErr.Message, "Email cannot be set for service accounts")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ServiceAccount/WithPassword", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
client, first := coderdenttest.New(t, &coderdenttest.Options{
|
||||||
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||||
|
Features: license.Features{
|
||||||
|
codersdk.FeatureServiceAccounts: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
//nolint:gocritic
|
||||||
|
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
||||||
|
OrganizationIDs: []uuid.UUID{first.OrganizationID},
|
||||||
|
Username: "service-acct-password",
|
||||||
|
Password: "ShouldNotHavePassword123!",
|
||||||
|
ServiceAccount: true,
|
||||||
|
})
|
||||||
|
var apiErr *codersdk.Error
|
||||||
|
require.ErrorAs(t, err, &apiErr)
|
||||||
|
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||||
|
require.Contains(t, apiErr.Message, "Password cannot be set for service accounts")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ServiceAccount/WithInvalidLoginType", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
client, first := coderdenttest.New(t, &coderdenttest.Options{
|
||||||
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||||
|
Features: license.Features{
|
||||||
|
codersdk.FeatureServiceAccounts: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
//nolint:gocritic
|
||||||
|
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
||||||
|
OrganizationIDs: []uuid.UUID{first.OrganizationID},
|
||||||
|
Username: "service-acct-login-type",
|
||||||
|
UserLoginType: codersdk.LoginTypePassword,
|
||||||
|
ServiceAccount: true,
|
||||||
|
})
|
||||||
|
var apiErr *codersdk.Error
|
||||||
|
require.ErrorAs(t, err, &apiErr)
|
||||||
|
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||||
|
require.Contains(t, apiErr.Message, "Service accounts must use login type 'none'")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ServiceAccount/DefaultLoginType", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
client, first := coderdenttest.New(t, &coderdenttest.Options{
|
||||||
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||||
|
Features: license.Features{
|
||||||
|
codersdk.FeatureServiceAccounts: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
//nolint:gocritic
|
||||||
|
user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
||||||
|
OrganizationIDs: []uuid.UUID{first.OrganizationID},
|
||||||
|
Username: "service-acct-default-login",
|
||||||
|
ServiceAccount: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
found, err := client.User(ctx, user.ID.String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, codersdk.LoginTypeNone, found.LoginType)
|
||||||
|
require.Empty(t, found.Email)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ServiceAccount/MultipleWithoutEmail", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
client, first := coderdenttest.New(t, &coderdenttest.Options{
|
||||||
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||||
|
Features: license.Features{
|
||||||
|
codersdk.FeatureServiceAccounts: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
//nolint:gocritic
|
||||||
|
user1, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
||||||
|
OrganizationIDs: []uuid.UUID{first.OrganizationID},
|
||||||
|
Username: "service-acct-multi-1",
|
||||||
|
ServiceAccount: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, user1.Email)
|
||||||
|
|
||||||
|
user2, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
||||||
|
OrganizationIDs: []uuid.UUID{first.OrganizationID},
|
||||||
|
Username: "service-acct-multi-2",
|
||||||
|
ServiceAccount: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, user2.Email)
|
||||||
|
require.NotEqual(t, user1.ID, user2.ID)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,7 +231,13 @@ func TestWorkspaceSharingDisabled(t *testing.T) {
|
|||||||
t.Run("ACLEndpointsForbiddenServiceAccountsMode", func(t *testing.T) {
|
t.Run("ACLEndpointsForbiddenServiceAccountsMode", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
client, db, owner := coderdenttest.NewWithDatabase(t, nil)
|
client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
|
||||||
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||||
|
Features: license.Features{
|
||||||
|
codersdk.FeatureServiceAccounts: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
regularClient, regularUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
regularClient, regularUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||||
regularWS := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
regularWS := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||||
@@ -444,7 +450,8 @@ func TestWorkspaceSharingDisabled(t *testing.T) {
|
|||||||
},
|
},
|
||||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||||
Features: license.Features{
|
Features: license.Features{
|
||||||
codersdk.FeatureTemplateRBAC: 1,
|
codersdk.FeatureTemplateRBAC: 1,
|
||||||
|
codersdk.FeatureServiceAccounts: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+2
@@ -3511,6 +3511,7 @@ export type FeatureName =
|
|||||||
| "multiple_external_auth"
|
| "multiple_external_auth"
|
||||||
| "multiple_organizations"
|
| "multiple_organizations"
|
||||||
| "scim"
|
| "scim"
|
||||||
|
| "service_accounts"
|
||||||
| "task_batch_actions"
|
| "task_batch_actions"
|
||||||
| "template_rbac"
|
| "template_rbac"
|
||||||
| "user_limit"
|
| "user_limit"
|
||||||
@@ -3539,6 +3540,7 @@ export const FeatureNames: FeatureName[] = [
|
|||||||
"multiple_external_auth",
|
"multiple_external_auth",
|
||||||
"multiple_organizations",
|
"multiple_organizations",
|
||||||
"scim",
|
"scim",
|
||||||
|
"service_accounts",
|
||||||
"task_batch_actions",
|
"task_batch_actions",
|
||||||
"template_rbac",
|
"template_rbac",
|
||||||
"user_limit",
|
"user_limit",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const meta: Meta<typeof CreateUserForm> = {
|
|||||||
onCancel: action("cancel"),
|
onCancel: action("cancel"),
|
||||||
onSubmit: action("submit"),
|
onSubmit: action("submit"),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
serviceAccountsEnabled: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ interface CreateUserFormProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
authMethods?: TypesGen.AuthMethods;
|
authMethods?: TypesGen.AuthMethods;
|
||||||
showOrganizations: boolean;
|
showOrganizations: boolean;
|
||||||
|
serviceAccountsEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CreateUserForm: FC<CreateUserFormProps> = ({
|
export const CreateUserForm: FC<CreateUserFormProps> = ({
|
||||||
@@ -96,12 +97,13 @@ export const CreateUserForm: FC<CreateUserFormProps> = ({
|
|||||||
onCancel,
|
onCancel,
|
||||||
showOrganizations,
|
showOrganizations,
|
||||||
authMethods,
|
authMethods,
|
||||||
|
serviceAccountsEnabled,
|
||||||
}) => {
|
}) => {
|
||||||
const availableLoginTypes = [
|
const availableLoginTypes = [
|
||||||
authMethods?.password.enabled && "password",
|
authMethods?.password.enabled && "password",
|
||||||
authMethods?.oidc.enabled && "oidc",
|
authMethods?.oidc.enabled && "oidc",
|
||||||
authMethods?.github.enabled && "github",
|
authMethods?.github.enabled && "github",
|
||||||
"none",
|
serviceAccountsEnabled && "none",
|
||||||
].filter(Boolean) as Array<keyof typeof loginTypeOptions>;
|
].filter(Boolean) as Array<keyof typeof loginTypeOptions>;
|
||||||
|
|
||||||
const defaultLoginType = availableLoginTypes[0];
|
const defaultLoginType = availableLoginTypes[0];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getErrorDetail, getErrorMessage } from "#/api/errors";
|
|||||||
import { authMethods, createUser } from "#/api/queries/users";
|
import { authMethods, createUser } from "#/api/queries/users";
|
||||||
import { Margins } from "#/components/Margins/Margins";
|
import { Margins } from "#/components/Margins/Margins";
|
||||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||||
|
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
|
||||||
import { pageTitle } from "#/utils/page";
|
import { pageTitle } from "#/utils/page";
|
||||||
import { CreateUserForm } from "./CreateUserForm";
|
import { CreateUserForm } from "./CreateUserForm";
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ const CreateUserPage: FC = () => {
|
|||||||
const createUserMutation = useMutation(createUser(queryClient));
|
const createUserMutation = useMutation(createUser(queryClient));
|
||||||
const authMethodsQuery = useQuery(authMethods());
|
const authMethodsQuery = useQuery(authMethods());
|
||||||
const { showOrganizations } = useDashboard();
|
const { showOrganizations } = useDashboard();
|
||||||
|
const { service_accounts: serviceAccountsEnabled } = useFeatureVisibility();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Margins>
|
<Margins>
|
||||||
@@ -58,6 +60,7 @@ const CreateUserPage: FC = () => {
|
|||||||
}}
|
}}
|
||||||
authMethods={authMethodsQuery.data}
|
authMethods={authMethodsQuery.data}
|
||||||
showOrganizations={showOrganizations}
|
showOrganizations={showOrganizations}
|
||||||
|
serviceAccountsEnabled={serviceAccountsEnabled}
|
||||||
/>
|
/>
|
||||||
</Margins>
|
</Margins>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user