Skip to content

Commit 3a099ba

Browse files
committed
chore: add authorization check endpoint
1 parent 39e9b6f commit 3a099ba

17 files changed

+554
-217
lines changed

coderd/authorize.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package coderd
22

33
import (
4+
"fmt"
45
"net/http"
56

67
"golang.org/x/xerrors"
78

89
"cdr.dev/slog"
10+
"github.com/coder/coder/coderd/httpapi"
911
"github.com/coder/coder/coderd/httpmw"
1012
"github.com/coder/coder/coderd/rbac"
13+
"github.com/coder/coder/codersdk"
1114
)
1215

1316
func AuthorizeFilter[O rbac.Objecter](h *HTTPAuthorizer, r *http.Request, action rbac.Action, objects []O) ([]O, error) {
@@ -81,3 +84,97 @@ func (h *HTTPAuthorizer) Authorize(r *http.Request, action rbac.Action, object r
8184
}
8285
return true
8386
}
87+
88+
// checkAuthorization returns if the current API key can use the given
89+
// permissions, factoring in the current user's roles and the API key scopes.
90+
func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) {
91+
auth := httpmw.UserAuthorization(r)
92+
93+
var params codersdk.AuthorizationRequest
94+
if !httpapi.Read(rw, r, &params) {
95+
return
96+
}
97+
98+
api.Logger.Warn(r.Context(), "check-auth",
99+
slog.F("my_id", httpmw.APIKey(r).UserID),
100+
slog.F("got_id", auth.ID),
101+
slog.F("name", auth.Username),
102+
slog.F("roles", auth.Roles), slog.F("scope", auth.Scope),
103+
)
104+
105+
response := make(codersdk.AuthorizationResponse)
106+
for k, v := range params.Checks {
107+
if v.Object.ResourceType == "" {
108+
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
109+
Message: fmt.Sprintf("Object's \"resource_type\" field must be defined for key %q.", k),
110+
})
111+
return
112+
}
113+
114+
if v.Object.OwnerID == "me" {
115+
v.Object.OwnerID = auth.ID.String()
116+
}
117+
err := api.Authorizer.ByRoleName(r.Context(), auth.ID.String(), auth.Roles, auth.Scope.ToRBAC(), rbac.Action(v.Action),
118+
rbac.Object{
119+
Owner: v.Object.OwnerID,
120+
OrgID: v.Object.OrganizationID,
121+
Type: v.Object.ResourceType,
122+
})
123+
response[k] = err == nil
124+
}
125+
126+
httpapi.Write(rw, http.StatusOK, response)
127+
}
128+
129+
// checkUserPermissions returns if the given user can perform certain actions.
130+
// It does not check scopes.
131+
func (api *API) checkUserPermissions(rw http.ResponseWriter, r *http.Request) {
132+
user := httpmw.UserParam(r)
133+
134+
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceUser) {
135+
httpapi.ResourceNotFound(rw)
136+
return
137+
}
138+
139+
// use the roles of the user specified, not the person making the request.
140+
roles, err := api.Database.GetAuthorizationUserRoles(r.Context(), user.ID)
141+
if err != nil {
142+
httpapi.Forbidden(rw)
143+
return
144+
}
145+
146+
api.Logger.Warn(r.Context(), "check-user-auth",
147+
slog.F("my_id", httpmw.APIKey(r).UserID),
148+
slog.F("got_id", roles.ID),
149+
slog.F("name", roles.Username),
150+
slog.F("roles", roles.Roles),
151+
)
152+
153+
var params codersdk.AuthorizationRequest
154+
if !httpapi.Read(rw, r, &params) {
155+
return
156+
}
157+
158+
response := make(codersdk.AuthorizationResponse)
159+
for k, v := range params.Checks {
160+
if v.Object.ResourceType == "" {
161+
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
162+
Message: fmt.Sprintf("Object's \"resource_type\" field must be defined for key %q.", k),
163+
})
164+
return
165+
}
166+
167+
if v.Object.OwnerID == "me" {
168+
v.Object.OwnerID = roles.ID.String()
169+
}
170+
err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, rbac.ScopeAll, rbac.Action(v.Action),
171+
rbac.Object{
172+
Owner: v.Object.OwnerID,
173+
OrgID: v.Object.OrganizationID,
174+
Type: v.Object.ResourceType,
175+
})
176+
response[k] = err == nil
177+
}
178+
179+
httpapi.Write(rw, http.StatusOK, response)
180+
}

coderd/authorize_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package coderd_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/google/uuid"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/coder/coder/coderd/coderdtest"
11+
"github.com/coder/coder/coderd/rbac"
12+
"github.com/coder/coder/codersdk"
13+
"github.com/coder/coder/testutil"
14+
)
15+
16+
func TestCheckPermissions(t *testing.T) {
17+
t.Parallel()
18+
19+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
20+
t.Cleanup(cancel)
21+
22+
adminClient := coderdtest.New(t, nil)
23+
// Create adminClient, member, and org adminClient
24+
adminUser := coderdtest.CreateFirstUser(t, adminClient)
25+
memberClient := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID)
26+
memberUser, err := memberClient.User(ctx, codersdk.Me)
27+
require.NoError(t, err)
28+
orgAdminClient := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID, rbac.RoleOrgAdmin(adminUser.OrganizationID))
29+
orgAdminUser, err := orgAdminClient.User(ctx, codersdk.Me)
30+
require.NoError(t, err)
31+
32+
// With admin, member, and org admin
33+
const (
34+
readAllUsers = "read-all-users"
35+
readOrgWorkspaces = "read-org-workspaces"
36+
readMyself = "read-myself"
37+
readOwnWorkspaces = "read-own-workspaces"
38+
)
39+
params := map[string]codersdk.AuthorizationCheck{
40+
readAllUsers: {
41+
Object: codersdk.AuthorizationObject{
42+
ResourceType: "users",
43+
},
44+
Action: "read",
45+
},
46+
readMyself: {
47+
Object: codersdk.AuthorizationObject{
48+
ResourceType: "users",
49+
OwnerID: "me",
50+
},
51+
Action: "read",
52+
},
53+
readOwnWorkspaces: {
54+
Object: codersdk.AuthorizationObject{
55+
ResourceType: "workspaces",
56+
OwnerID: "me",
57+
},
58+
Action: "read",
59+
},
60+
readOrgWorkspaces: {
61+
Object: codersdk.AuthorizationObject{
62+
ResourceType: "workspaces",
63+
OrganizationID: adminUser.OrganizationID.String(),
64+
},
65+
Action: "read",
66+
},
67+
}
68+
69+
testCases := []struct {
70+
Name string
71+
Client *codersdk.Client
72+
UserID uuid.UUID
73+
Check codersdk.AuthorizationResponse
74+
}{
75+
{
76+
Name: "Admin",
77+
Client: adminClient,
78+
UserID: adminUser.UserID,
79+
Check: map[string]bool{
80+
readAllUsers: true,
81+
readMyself: true,
82+
readOwnWorkspaces: true,
83+
readOrgWorkspaces: true,
84+
},
85+
},
86+
{
87+
Name: "OrgAdmin",
88+
Client: orgAdminClient,
89+
UserID: orgAdminUser.ID,
90+
Check: map[string]bool{
91+
readAllUsers: false,
92+
readMyself: true,
93+
readOwnWorkspaces: true,
94+
readOrgWorkspaces: true,
95+
},
96+
},
97+
{
98+
Name: "Member",
99+
Client: memberClient,
100+
UserID: memberUser.ID,
101+
Check: map[string]bool{
102+
readAllUsers: false,
103+
readMyself: true,
104+
readOwnWorkspaces: true,
105+
readOrgWorkspaces: false,
106+
},
107+
},
108+
}
109+
110+
for _, c := range testCases {
111+
c := c
112+
113+
t.Run("CheckUserPermissions/"+c.Name, func(t *testing.T) {
114+
t.Parallel()
115+
116+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
117+
t.Cleanup(cancel)
118+
119+
resp, err := adminClient.CheckUserPermissions(ctx, c.UserID, codersdk.AuthorizationRequest{Checks: params})
120+
require.NoError(t, err, "check perms")
121+
require.Equal(t, c.Check, resp)
122+
})
123+
124+
t.Run("CheckAuthorization/"+c.Name, func(t *testing.T) {
125+
t.Parallel()
126+
127+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
128+
t.Cleanup(cancel)
129+
130+
resp, err := c.Client.CheckAuthorization(ctx, codersdk.AuthorizationRequest{Checks: params})
131+
require.NoError(t, err, "check perms")
132+
require.Equal(t, c.Check, resp)
133+
})
134+
}
135+
}

coderd/coderd.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ func New(options *Options) *API {
390390
r.Put("/roles", api.putUserRoles)
391391
r.Get("/roles", api.userRoles)
392392

393-
r.Post("/authorization", api.checkPermissions)
393+
r.Post("/authorization", api.checkUserPermissions)
394394

395395
r.Route("/keys", func(r chi.Router) {
396396
r.Post("/", api.postAPIKey)
@@ -497,6 +497,21 @@ func New(options *Options) *API {
497497
r.Use(apiKeyMiddleware)
498498
r.Mount("/", options.LicenseHandler)
499499
})
500+
r.Route("/authorization", func(r chi.Router) {
501+
r.Route("/can-i", func(r chi.Router) {
502+
r.Use(apiKeyMiddleware)
503+
r.Post("/", api.checkAuthorization)
504+
})
505+
506+
r.Route("/application-auth", func(r chi.Router) {
507+
// We do want to redirect back on success.
508+
r.Use(httpmw.ExtractAPIKey(options.Database, oauthConfigs, true))
509+
510+
// This is a GET request as it's redirected to by the subdomain app
511+
// handler and the login page.
512+
r.Get("/", api.workspaceApplicationAuth)
513+
})
514+
})
500515
})
501516

502517
r.NotFound(compressHandler(http.HandlerFunc(api.siteHandler.ServeHTTP)).ServeHTTP)

coderd/coderd_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
4343
t.Parallel()
4444
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
4545
defer cancel()
46-
a := coderdtest.NewAuthTester(ctx, t, nil)
46+
a := coderdtest.NewAuthTester(ctx, t, &coderdtest.Options{
47+
AppHostname: "test.coder.com",
48+
})
4749
skipRoutes, assertRoute := coderdtest.AGPLRoutes(a)
4850
a.Test(ctx, assertRoute, skipRoutes)
4951
}

coderd/coderdtest/authtest.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -174,14 +174,15 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
174174

175175
assertRoute := map[string]RouteCheck{
176176
// These endpoints do not require auth
177-
"GET:/api/v2": {NoAuthorize: true},
178-
"GET:/api/v2/buildinfo": {NoAuthorize: true},
179-
"GET:/api/v2/users/first": {NoAuthorize: true},
180-
"POST:/api/v2/users/first": {NoAuthorize: true},
181-
"POST:/api/v2/users/login": {NoAuthorize: true},
182-
"GET:/api/v2/users/authmethods": {NoAuthorize: true},
183-
"POST:/api/v2/csp/reports": {NoAuthorize: true},
184-
"GET:/api/v2/entitlements": {NoAuthorize: true},
177+
"GET:/api/v2": {NoAuthorize: true},
178+
"GET:/api/v2/buildinfo": {NoAuthorize: true},
179+
"GET:/api/v2/users/first": {NoAuthorize: true},
180+
"POST:/api/v2/users/first": {NoAuthorize: true},
181+
"POST:/api/v2/users/login": {NoAuthorize: true},
182+
"GET:/api/v2/users/authmethods": {NoAuthorize: true},
183+
"POST:/api/v2/csp/reports": {NoAuthorize: true},
184+
"GET:/api/v2/entitlements": {NoAuthorize: true},
185+
"POST:/api/v2/authorization/can-i": {NoAuthorize: true},
185186

186187
// Has it's own auth
187188
"GET:/api/v2/users/oauth2/github/callback": {NoAuthorize: true},
@@ -386,7 +387,8 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
386387
AssertAction: rbac.ActionRead,
387388
AssertObject: workspaceRBACObj,
388389
},
389-
"GET:/api/v2/users": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceUser},
390+
"GET:/api/v2/users": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceUser},
391+
"GET:/api/v2/authorization/application-auth": {AssertAction: rbac.ActionCreate, AssertObject: rbac.ResourceAPIKey},
390392

391393
// These endpoints need payloads to get to the auth part. Payloads will be required
392394
"PUT:/api/v2/users/{user}/roles": {StatusCode: http.StatusBadRequest, NoAuthorize: true},

coderd/roles.go

Lines changed: 0 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package coderd
22

33
import (
4-
"fmt"
54
"net/http"
65

76
"github.com/coder/coder/coderd/httpmw"
@@ -37,51 +36,6 @@ func (api *API) assignableOrgRoles(rw http.ResponseWriter, r *http.Request) {
3736
httpapi.Write(rw, http.StatusOK, assignableRoles(actorRoles.Roles, roles))
3837
}
3938

40-
func (api *API) checkPermissions(rw http.ResponseWriter, r *http.Request) {
41-
user := httpmw.UserParam(r)
42-
apiKey := httpmw.APIKey(r)
43-
44-
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceUser) {
45-
httpapi.ResourceNotFound(rw)
46-
return
47-
}
48-
49-
// use the roles of the user specified, not the person making the request.
50-
roles, err := api.Database.GetAuthorizationUserRoles(r.Context(), user.ID)
51-
if err != nil {
52-
httpapi.Forbidden(rw)
53-
return
54-
}
55-
56-
var params codersdk.UserAuthorizationRequest
57-
if !httpapi.Read(rw, r, &params) {
58-
return
59-
}
60-
61-
response := make(codersdk.UserAuthorizationResponse)
62-
for k, v := range params.Checks {
63-
if v.Object.ResourceType == "" {
64-
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
65-
Message: fmt.Sprintf("Object's \"resource_type\" field must be defined for key %q.", k),
66-
})
67-
return
68-
}
69-
70-
if v.Object.OwnerID == "me" {
71-
v.Object.OwnerID = roles.ID.String()
72-
}
73-
err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, apiKey.Scope.ToRBAC(), rbac.Action(v.Action),
74-
rbac.Object{
75-
Owner: v.Object.OwnerID,
76-
OrgID: v.Object.OrganizationID,
77-
Type: v.Object.ResourceType,
78-
})
79-
response[k] = err == nil
80-
}
81-
82-
httpapi.Write(rw, http.StatusOK, response)
83-
}
84-
8539
func convertRole(role rbac.Role) codersdk.Role {
8640
return codersdk.Role{
8741
DisplayName: role.DisplayName,

0 commit comments

Comments
 (0)