Skip to content

Commit 4f6aac8

Browse files
committed
chore: add test for app sharing with scoped keys
1 parent 89a75d0 commit 4f6aac8

File tree

11 files changed

+146
-50
lines changed

11 files changed

+146
-50
lines changed

cli/tokens.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ func createToken() *cobra.Command {
5555
return xerrors.Errorf("create codersdk client: %w", err)
5656
}
5757

58-
res, err := client.CreateToken(cmd.Context(), codersdk.Me)
58+
res, err := client.CreateToken(cmd.Context(), codersdk.Me, codersdk.CreateTokenRequest{})
5959
if err != nil {
6060
return xerrors.Errorf("create tokens: %w", err)
6161
}

coderd/apikey.go

+17
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,23 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
3434
return
3535
}
3636

37+
var createToken codersdk.CreateTokenRequest
38+
if !httpapi.Read(ctx, rw, r, &createToken) {
39+
return
40+
}
41+
42+
scope := database.APIKeyScopeAll
43+
if scope != "" {
44+
scope = database.APIKeyScope(createToken.Scope)
45+
}
46+
3747
// tokens last 100 years
3848
lifeTime := time.Hour * 876000
3949
cookie, err := api.createAPIKey(ctx, createAPIKeyParams{
4050
UserID: user.ID,
4151
LoginType: database.LoginTypeToken,
4252
ExpiresAt: database.Now().Add(lifeTime),
53+
Scope: scope,
4354
LifetimeSeconds: int64(lifeTime.Seconds()),
4455
})
4556
if err != nil {
@@ -54,6 +65,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
5465
}
5566

5667
// Creates a new session key, used for logging in via the CLI.
68+
// DEPRECATED: use postToken instead.
5769
func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) {
5870
ctx := r.Context()
5971
user := httpmw.UserParam(r)
@@ -229,6 +241,11 @@ func (api *API) createAPIKey(ctx context.Context, params createAPIKeyParams) (*h
229241
if params.Scope != "" {
230242
scope = params.Scope
231243
}
244+
switch scope {
245+
case database.APIKeyScopeAll, database.APIKeyScopeApplicationConnect:
246+
default:
247+
return nil, xerrors.Errorf("invalid API key scope: %q", scope)
248+
}
232249

233250
key, err := api.Database.InsertAPIKey(ctx, database.InsertAPIKeyParams{
234251
ID: keyID,

coderd/apikey_test.go

+52-21
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,61 @@ import (
1414

1515
func TestTokens(t *testing.T) {
1616
t.Parallel()
17-
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
18-
defer cancel()
19-
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
20-
_ = coderdtest.CreateFirstUser(t, client)
21-
keys, err := client.GetTokens(ctx, codersdk.Me)
22-
require.NoError(t, err)
23-
require.Empty(t, keys)
2417

25-
res, err := client.CreateToken(ctx, codersdk.Me)
26-
require.NoError(t, err)
27-
require.Greater(t, len(res.Key), 2)
18+
t.Run("CRUD", func(t *testing.T) {
19+
t.Parallel()
2820

29-
keys, err = client.GetTokens(ctx, codersdk.Me)
30-
require.NoError(t, err)
31-
require.EqualValues(t, len(keys), 1)
32-
require.Contains(t, res.Key, keys[0].ID)
33-
// expires_at must be greater than 50 years
34-
require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*438300))
21+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
22+
defer cancel()
23+
client := coderdtest.New(t, nil)
24+
_ = coderdtest.CreateFirstUser(t, client)
25+
keys, err := client.GetTokens(ctx, codersdk.Me)
26+
require.NoError(t, err)
27+
require.Empty(t, keys)
3528

36-
err = client.DeleteAPIKey(ctx, codersdk.Me, keys[0].ID)
37-
require.NoError(t, err)
38-
keys, err = client.GetTokens(ctx, codersdk.Me)
39-
require.NoError(t, err)
40-
require.Empty(t, keys)
29+
res, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{})
30+
require.NoError(t, err)
31+
require.Greater(t, len(res.Key), 2)
32+
33+
keys, err = client.GetTokens(ctx, codersdk.Me)
34+
require.NoError(t, err)
35+
require.EqualValues(t, len(keys), 1)
36+
require.Contains(t, res.Key, keys[0].ID)
37+
// expires_at must be greater than 50 years
38+
require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*438300))
39+
require.Equal(t, codersdk.APIKeyScopeAll, keys[0].Scope)
40+
41+
// no update
42+
43+
err = client.DeleteAPIKey(ctx, codersdk.Me, keys[0].ID)
44+
require.NoError(t, err)
45+
keys, err = client.GetTokens(ctx, codersdk.Me)
46+
require.NoError(t, err)
47+
require.Empty(t, keys)
48+
})
49+
50+
t.Run("Scoped", func(t *testing.T) {
51+
t.Parallel()
52+
53+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
54+
defer cancel()
55+
client := coderdtest.New(t, nil)
56+
_ = coderdtest.CreateFirstUser(t, client)
57+
58+
res, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
59+
Scope: codersdk.APIKeyScopeApplicationConnect,
60+
})
61+
require.NoError(t, err)
62+
require.Greater(t, len(res.Key), 2)
63+
64+
keys, err := client.GetTokens(ctx, codersdk.Me)
65+
require.NoError(t, err)
66+
require.EqualValues(t, len(keys), 1)
67+
require.Contains(t, res.Key, keys[0].ID)
68+
// expires_at must be greater than 50 years
69+
require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*438300))
70+
require.Equal(t, keys[0].Scope, codersdk.APIKeyScopeApplicationConnect)
71+
})
4172
}
4273

4374
func TestAPIKey(t *testing.T) {

coderd/users.go

+1
Original file line numberDiff line numberDiff line change
@@ -1207,6 +1207,7 @@ func convertAPIKey(k database.APIKey) codersdk.APIKey {
12071207
CreatedAt: k.CreatedAt,
12081208
UpdatedAt: k.UpdatedAt,
12091209
LoginType: codersdk.LoginType(k.LoginType),
1210+
Scope: codersdk.APIKeyScope(k.Scope),
12101211
LifetimeSeconds: k.LifetimeSeconds,
12111212
}
12121213
}

coderd/users_test.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ func TestPostLogin(t *testing.T) {
286286
require.Equal(t, int64(86400), key.LifetimeSeconds, "default should be 86400")
287287

288288
// tokens have a longer life
289-
token, err := client.CreateToken(ctx, codersdk.Me)
289+
token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{})
290290
require.NoError(t, err, "make new token api key")
291291
split = strings.Split(token.Key, "-")
292292
apiKey, err := client.GetAPIKey(ctx, admin.UserID.String(), split[0])
@@ -1202,7 +1202,7 @@ func TestPostTokens(t *testing.T) {
12021202
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
12031203
defer cancel()
12041204

1205-
apiKey, err := client.CreateToken(ctx, codersdk.Me)
1205+
apiKey, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{})
12061206
require.NotNil(t, apiKey)
12071207
require.GreaterOrEqual(t, len(apiKey.Key), 2)
12081208
require.NoError(t, err)

coderd/workspaceapps.go

+12-1
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,18 @@ func (api *API) authorizeWorkspaceApp(r *http.Request, sharingLevel database.App
327327
return false, xerrors.Errorf("get template %q: %w", workspace.TemplateID, err)
328328
}
329329

330-
err = api.Authorizer.ByRoleName(ctx, roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), []string{}, rbac.ActionRead, template.RBACObject())
330+
// We have to perform this check without scopes enabled because
331+
// otherwise this check will always fail on a scoped API key.
332+
err = api.Authorizer.ByRoleName(ctx, roles.ID.String(), roles.Roles, rbac.ScopeAll, []string{}, rbac.ActionRead, template.RBACObject())
333+
if err != nil {
334+
// Exit early if the user doesn't have access to the template.
335+
return false, nil
336+
}
337+
338+
// Now check if the user has ApplicationConnect access to their own
339+
// workspaces.
340+
object := rbac.ResourceWorkspaceApplicationConnect.WithOwner(roles.ID.String())
341+
err = api.Authorizer.ByRoleName(ctx, roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), []string{}, rbac.ActionCreate, object)
331342
if err == nil {
332343
return true, nil
333344
}

coderd/workspaceapps_test.go

+12-1
Original file line numberDiff line numberDiff line change
@@ -836,7 +836,18 @@ func TestAppSharing(t *testing.T) {
836836
// If the client has a session token, we also want to check that a
837837
// scoped key works.
838838
clients := []*codersdk.Client{client}
839-
// TODO: generate scoped token and add to slice
839+
if client.SessionToken != "" {
840+
token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
841+
Scope: codersdk.APIKeyScopeApplicationConnect,
842+
})
843+
require.NoError(t, err)
844+
845+
scopedClient := codersdk.New(client.URL)
846+
scopedClient.SessionToken = token.Key
847+
scopedClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect
848+
849+
clients = append(clients, scopedClient)
850+
}
840851

841852
for i, client := range clients {
842853
msg := fmt.Sprintf("client %d", i)

codersdk/apikey.go

+38-18
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@ import (
1313
type APIKey struct {
1414
ID string `json:"id" validate:"required"`
1515
// NOTE: do not ever return the HashedSecret
16-
UserID uuid.UUID `json:"user_id" validate:"required"`
17-
LastUsed time.Time `json:"last_used" validate:"required"`
18-
ExpiresAt time.Time `json:"expires_at" validate:"required"`
19-
CreatedAt time.Time `json:"created_at" validate:"required"`
20-
UpdatedAt time.Time `json:"updated_at" validate:"required"`
21-
LoginType LoginType `json:"login_type" validate:"required"`
22-
LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"`
16+
UserID uuid.UUID `json:"user_id" validate:"required"`
17+
LastUsed time.Time `json:"last_used" validate:"required"`
18+
ExpiresAt time.Time `json:"expires_at" validate:"required"`
19+
CreatedAt time.Time `json:"created_at" validate:"required"`
20+
UpdatedAt time.Time `json:"updated_at" validate:"required"`
21+
LoginType LoginType `json:"login_type" validate:"required"`
22+
Scope APIKeyScope `json:"scope" validate:"required"`
23+
LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"`
2324
}
2425

2526
type LoginType string
@@ -31,32 +32,51 @@ const (
3132
LoginTypeToken LoginType = "token"
3233
)
3334

35+
type APIKeyScope string
36+
37+
const (
38+
APIKeyScopeAll APIKeyScope = "all"
39+
APIKeyScopeApplicationConnect APIKeyScope = "application_connect"
40+
)
41+
42+
type CreateTokenRequest struct {
43+
Scope APIKeyScope `json:"scope"`
44+
}
45+
46+
// GenerateAPIKeyResponse contains an API key for a user.
47+
type GenerateAPIKeyResponse struct {
48+
Key string `json:"key"`
49+
}
50+
3451
// CreateToken generates an API key that doesn't expire.
35-
func (c *Client) CreateToken(ctx context.Context, userID string) (*GenerateAPIKeyResponse, error) {
36-
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys/tokens", userID), nil)
52+
func (c *Client) CreateToken(ctx context.Context, userID string, req CreateTokenRequest) (GenerateAPIKeyResponse, error) {
53+
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys/tokens", userID), req)
3754
if err != nil {
38-
return nil, err
55+
return GenerateAPIKeyResponse{}, err
3956
}
4057
defer res.Body.Close()
4158
if res.StatusCode > http.StatusCreated {
42-
return nil, readBodyAsError(res)
59+
return GenerateAPIKeyResponse{}, readBodyAsError(res)
4360
}
44-
apiKey := &GenerateAPIKeyResponse{}
45-
return apiKey, json.NewDecoder(res.Body).Decode(apiKey)
61+
62+
var apiKey GenerateAPIKeyResponse
63+
return apiKey, json.NewDecoder(res.Body).Decode(&apiKey)
4664
}
4765

4866
// CreateAPIKey generates an API key for the user ID provided.
49-
func (c *Client) CreateAPIKey(ctx context.Context, user string) (*GenerateAPIKeyResponse, error) {
67+
// DEPRECATED: use CreateToken instead.
68+
func (c *Client) CreateAPIKey(ctx context.Context, user string) (GenerateAPIKeyResponse, error) {
5069
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys", user), nil)
5170
if err != nil {
52-
return nil, err
71+
return GenerateAPIKeyResponse{}, err
5372
}
5473
defer res.Body.Close()
5574
if res.StatusCode > http.StatusCreated {
56-
return nil, readBodyAsError(res)
75+
return GenerateAPIKeyResponse{}, readBodyAsError(res)
5776
}
58-
apiKey := &GenerateAPIKeyResponse{}
59-
return apiKey, json.NewDecoder(res.Body).Decode(apiKey)
77+
78+
var apiKey GenerateAPIKeyResponse
79+
return apiKey, json.NewDecoder(res.Body).Decode(&apiKey)
6080
}
6181

6282
// GetTokens list machine API keys.

codersdk/users.go

-5
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,6 @@ type LoginWithPasswordResponse struct {
9696
SessionToken string `json:"session_token" validate:"required"`
9797
}
9898

99-
// GenerateAPIKeyResponse contains an API key for a user.
100-
type GenerateAPIKeyResponse struct {
101-
Key string `json:"key"`
102-
}
103-
10499
type CreateOrganizationRequest struct {
105100
Name string `json:"name" validate:"required,username"`
106101
}

site/src/api/typesGenerated.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface APIKey {
99
readonly created_at: string
1010
readonly updated_at: string
1111
readonly login_type: LoginType
12+
readonly scope: APIKeyScope
1213
readonly lifetime_seconds: number
1314
}
1415

@@ -218,6 +219,11 @@ export interface CreateTestAuditLogRequest {
218219
readonly resource_id?: string
219220
}
220221

222+
// From codersdk/apikey.go
223+
export interface CreateTokenRequest {
224+
readonly scope: APIKeyScope
225+
}
226+
221227
// From codersdk/users.go
222228
export interface CreateUserRequest {
223229
readonly email: string
@@ -344,7 +350,7 @@ export interface Feature {
344350
readonly actual?: number
345351
}
346352

347-
// From codersdk/users.go
353+
// From codersdk/apikey.go
348354
export interface GenerateAPIKeyResponse {
349355
readonly key: string
350356
}
@@ -852,6 +858,9 @@ export interface WorkspaceResourceMetadata {
852858
readonly sensitive: boolean
853859
}
854860

861+
// From codersdk/apikey.go
862+
export type APIKeyScope = "all" | "application_connect"
863+
855864
// From codersdk/audit.go
856865
export type AuditAction = "create" | "delete" | "write"
857866

site/src/testHelpers/entities.ts

+1
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ export const MockWorkspaceApp: TypesGen.WorkspaceApp = {
199199
icon: "",
200200
subdomain: false,
201201
health: "disabled",
202+
sharing_level: "owner",
202203
healthcheck: {
203204
url: "",
204205
interval: 0,

0 commit comments

Comments
 (0)