Skip to content

Commit 30e2031

Browse files
committed
feat: First draft of adding authorize to an http endpoint
1 parent 7fb3c57 commit 30e2031

File tree

8 files changed

+318
-5
lines changed

8 files changed

+318
-5
lines changed

coderd/coderd.go

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/go-chi/chi/v5"
1313
"github.com/pion/webrtc/v3"
14+
"golang.org/x/xerrors"
1415
"google.golang.org/api/idtoken"
1516

1617
chitrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-chi/chi.v5"
@@ -22,6 +23,7 @@ import (
2223
"github.com/coder/coder/coderd/gitsshkey"
2324
"github.com/coder/coder/coderd/httpapi"
2425
"github.com/coder/coder/coderd/httpmw"
26+
"github.com/coder/coder/coderd/rbac"
2527
"github.com/coder/coder/coderd/turnconn"
2628
"github.com/coder/coder/codersdk"
2729
"github.com/coder/coder/site"
@@ -47,6 +49,7 @@ type Options struct {
4749
SecureAuthCookie bool
4850
SSHKeygenAlgorithm gitsshkey.Algorithm
4951
TURNServer *turnconn.Server
52+
Authorizer *rbac.RegoAuthorizer
5053
}
5154

5255
// New constructs the Coder API into an HTTP handler.
@@ -60,13 +63,28 @@ func New(options *Options) (http.Handler, func()) {
6063
if options.APIRateLimit == 0 {
6164
options.APIRateLimit = 512
6265
}
66+
if options.Authorizer == nil {
67+
var err error
68+
options.Authorizer, err = rbac.NewAuthorizer()
69+
if err != nil {
70+
// This should never happen, as the unit tests would fail if the
71+
// default built in authorizer failed.
72+
panic(xerrors.Errorf("rego authorize panic: %w", err))
73+
}
74+
}
6375
api := &api{
6476
Options: options,
6577
}
6678
apiKeyMiddleware := httpmw.ExtractAPIKey(options.Database, &httpmw.OAuth2Configs{
6779
Github: options.GithubOAuth2Config,
6880
})
6981

82+
authRolesMiddleware := httpmw.ExtractUserRoles(options.Database)
83+
84+
authorize := func(actions ...rbac.Action) func(http.Handler) http.Handler {
85+
return httpmw.Authorize(api.Logger, api.Authorizer, actions...)
86+
}
87+
7088
r := chi.NewRouter()
7189
r.Route("/api/v2", func(r chi.Router) {
7290
r.Use(
@@ -102,6 +120,9 @@ func New(options *Options) (http.Handler, func()) {
102120
r.Use(
103121
apiKeyMiddleware,
104122
httpmw.ExtractOrganizationParam(options.Database),
123+
authRolesMiddleware,
124+
// All authorize() functions will be scoped to this organization
125+
httpmw.InOrg(httpmw.OrganizationParam),
105126
)
106127
r.Get("/", api.organization)
107128
r.Get("/provisionerdaemons", api.provisionerDaemonsByOrganization)
@@ -121,6 +142,9 @@ func New(options *Options) (http.Handler, func()) {
121142
})
122143
})
123144
r.Route("/members", func(r chi.Router) {
145+
r.Route("/roles", func(r chi.Router) {
146+
r.With(httpmw.Object(rbac.ResourceUserRole), authorize(rbac.ActionCreate, rbac.ActionDelete)).Get("/", api.assignableOrgRoles)
147+
})
124148
r.Route("/{user}", func(r chi.Router) {
125149
r.Use(
126150
httpmw.ExtractUserParam(options.Database),
@@ -183,20 +207,28 @@ func New(options *Options) (http.Handler, func()) {
183207
})
184208
})
185209
r.Group(func(r chi.Router) {
186-
r.Use(apiKeyMiddleware)
210+
r.Use(
211+
apiKeyMiddleware,
212+
authRolesMiddleware,
213+
)
187214
r.Post("/", api.postUser)
188215
r.Get("/", api.users)
216+
// These routes query information about site wide roles.
217+
r.Route("/roles", func(r chi.Router) {
218+
// Can create/delete all roles to view this endpoint
219+
r.With(httpmw.Object(rbac.ResourceUserRole), authorize(rbac.ActionCreate, rbac.ActionDelete)).Get("/", api.assignableSiteRoles)
220+
})
189221
r.Route("/{user}", func(r chi.Router) {
190222
r.Use(httpmw.ExtractUserParam(options.Database))
191223
r.Get("/", api.userByName)
192224
r.Put("/profile", api.putUserProfile)
193225
r.Put("/suspend", api.putUserSuspend)
194-
// TODO: @emyrk Might want to move these to a /roles group instead of /user.
195-
// As we include more roles like org roles, it makes less sense to scope these here.
196-
r.Put("/roles", api.putUserRoles)
197-
r.Get("/roles", api.userRoles)
198226
r.Get("/organizations", api.organizationsByUser)
199227
r.Post("/organizations", api.postOrganizationsByUser)
228+
// These roles apply to the site wide permissions.
229+
r.Put("/roles", api.putUserRoles)
230+
r.Get("/roles", api.userRoles)
231+
200232
r.Post("/keys", api.postAPIKey)
201233
r.Route("/organizations", func(r chi.Router) {
202234
r.Post("/", api.postOrganizationsByUser)

coderd/database/querier.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries.sql.go

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/users.sql

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,15 @@ SET
122122
updated_at = $3
123123
WHERE
124124
id = $1 RETURNING *;
125+
126+
127+
-- name: GetAllUserRoles :many
128+
SELECT
129+
-- username is returned just to help for logging purposes
130+
id, username, array_cat(users.rbac_roles, organization_members.roles) :: text[] AS roles
131+
FROM
132+
users
133+
LEFT JOIN organization_members
134+
ON id = user_id
135+
WHERE
136+
id = @user_id;

coderd/httpmw/authorize.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package httpmw
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
8+
"github.com/google/uuid"
9+
10+
"golang.org/x/xerrors"
11+
12+
"cdr.dev/slog"
13+
"github.com/coder/coder/coderd/database"
14+
"github.com/coder/coder/coderd/httpapi"
15+
"github.com/coder/coder/coderd/rbac"
16+
)
17+
18+
type AuthObject struct {
19+
// WithUser sets the owner of the object to the value returned by the func
20+
WithUser func(r *http.Request) uuid.UUID
21+
22+
// InOrg sets the org owner of the object to the value returned by the func
23+
InOrg func(r *http.Request) uuid.UUID
24+
25+
// WithOwner sets the object id to the value returned by the func
26+
WithOwner func(r *http.Request) uuid.UUID
27+
28+
// Object is that base static object the above functions can modify.
29+
Object rbac.Object
30+
//// Actions are the various actions the middleware will check can be done on the object.
31+
//Actions []rbac.Action
32+
}
33+
34+
func WithOwner(owner func(r *http.Request) database.User) func(http.Handler) http.Handler {
35+
return func(next http.Handler) http.Handler {
36+
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
37+
ao := GetAuthObject(r)
38+
ao.WithOwner = func(r *http.Request) uuid.UUID {
39+
return owner(r).ID
40+
}
41+
42+
ctx := context.WithValue(r.Context(), authObjectKey{}, ao)
43+
next.ServeHTTP(rw, r.WithContext(ctx))
44+
})
45+
}
46+
}
47+
48+
func InOrg(org func(r *http.Request) database.Organization) func(http.Handler) http.Handler {
49+
return func(next http.Handler) http.Handler {
50+
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
51+
ao := GetAuthObject(r)
52+
ao.InOrg = func(r *http.Request) uuid.UUID {
53+
return org(r).ID
54+
}
55+
56+
ctx := context.WithValue(r.Context(), authObjectKey{}, ao)
57+
next.ServeHTTP(rw, r.WithContext(ctx))
58+
})
59+
}
60+
}
61+
62+
// Authorize allows for static object & action authorize checking. If the object is a static object, this is an easy way
63+
// to enforce the route.
64+
func Authorize(logger slog.Logger, auth *rbac.RegoAuthorizer, actions ...rbac.Action) func(http.Handler) http.Handler {
65+
return func(next http.Handler) http.Handler {
66+
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
67+
roles := UserRoles(r)
68+
args := GetAuthObject(r)
69+
70+
object := args.Object
71+
if args.InOrg != nil {
72+
object.InOrg(args.InOrg(r))
73+
}
74+
if args.WithUser != nil {
75+
object.WithOwner(args.InOrg(r).String())
76+
}
77+
if args.WithOwner != nil {
78+
object.WithID(args.InOrg(r).String())
79+
}
80+
81+
// Error on the first action that fails
82+
for _, act := range actions {
83+
err := auth.AuthorizeByRoleName(r.Context(), roles.ID.String(), roles.Roles, act, object)
84+
if err != nil {
85+
var internalError *rbac.UnauthorizedError
86+
if xerrors.As(err, internalError) {
87+
logger = logger.With(slog.F("internal", internalError.Internal()))
88+
}
89+
logger.Warn(r.Context(), "unauthorized",
90+
slog.F("roles", roles.Roles),
91+
slog.F("user_id", roles.ID),
92+
slog.F("username", roles.Username),
93+
slog.F("route", r.URL.Path),
94+
slog.F("action", act),
95+
slog.F("object", object),
96+
)
97+
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
98+
Message: err.Error(),
99+
})
100+
return
101+
}
102+
}
103+
next.ServeHTTP(rw, r)
104+
})
105+
}
106+
}
107+
108+
type authObjectKey struct{}
109+
110+
// APIKey returns the API key from the ExtractAPIKey handler.
111+
func GetAuthObject(r *http.Request) AuthObject {
112+
obj, ok := r.Context().Value(authObjectKey{}).(AuthObject)
113+
if !ok {
114+
return AuthObject{}
115+
}
116+
return obj
117+
}
118+
119+
func Object(object rbac.Object) func(http.Handler) http.Handler {
120+
return func(next http.Handler) http.Handler {
121+
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
122+
ao := GetAuthObject(r)
123+
ao.Object = object
124+
125+
ctx := context.WithValue(r.Context(), authObjectKey{}, ao)
126+
next.ServeHTTP(rw, r.WithContext(ctx))
127+
})
128+
}
129+
}
130+
131+
// User roles are the 'subject' field of Authorize()
132+
type userRolesKey struct{}
133+
134+
// APIKey returns the API key from the ExtractAPIKey handler.
135+
func UserRoles(r *http.Request) database.GetAllUserRolesRow {
136+
apiKey, ok := r.Context().Value(userRolesKey{}).(database.GetAllUserRolesRow)
137+
if !ok {
138+
panic("developer error: user roles middleware not provided")
139+
}
140+
return apiKey
141+
}
142+
143+
// ExtractUserRoles requires authentication using a valid API key.
144+
func ExtractUserRoles(db database.Store) func(http.Handler) http.Handler {
145+
return func(next http.Handler) http.Handler {
146+
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
147+
apiKey := APIKey(r)
148+
role, err := db.GetAllUserRoles(r.Context(), apiKey.UserID)
149+
if err != nil {
150+
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
151+
Message: fmt.Sprintf("roles not found", AuthCookie),
152+
})
153+
return
154+
}
155+
156+
ctx := context.WithValue(r.Context(), userRolesKey{}, role)
157+
next.ServeHTTP(rw, r.WithContext(ctx))
158+
})
159+
}
160+
}

coderd/rbac/builtin.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,43 @@ func IsOrgRole(roleName string) (string, bool) {
145145
return "", false
146146
}
147147

148+
// ListOrgRoles lists all roles that can be applied to an organization user
149+
// in the given organization.
150+
// Note: This should be a list in a database, but until then we build
151+
// the list from the builtins.
152+
func ListOrgRoles(organizationID uuid.UUID) []string {
153+
var roles []string
154+
for role := range builtInRoles {
155+
_, scope, err := roleSplit(role)
156+
if err != nil {
157+
// This should never happen
158+
continue
159+
}
160+
if scope == organizationID.String() {
161+
roles = append(roles, role)
162+
}
163+
}
164+
return roles
165+
}
166+
167+
// ListSiteRoles lists all roles that can be applied to a user.
168+
// Note: This should be a list in a database, but until then we build
169+
// the list from the builtins.
170+
func ListSiteRoles() []string {
171+
var roles []string
172+
for role := range builtInRoles {
173+
_, scope, err := roleSplit(role)
174+
if err != nil {
175+
// This should never happen
176+
continue
177+
}
178+
if scope == "" {
179+
roles = append(roles, role)
180+
}
181+
}
182+
return roles
183+
}
184+
148185
// roleName is a quick helper function to return
149186
// role_name:scopeID
150187
// If no scopeID is required, only 'role_name' is returned

coderd/rbac/object.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ var (
1717
Type: "template",
1818
}
1919

20+
// ResourceUserRole might be expanded later to allow more granular permissions
21+
// to modifying roles. For now, this covers all possible roles, so having this permission
22+
// allows granting/deleting **ALL** roles.
23+
ResourceUserRole = Object{
24+
Type: "user_role",
25+
}
26+
2027
// ResourceWildcard represents all resource types
2128
ResourceWildcard = Object{
2229
Type: WildcardSymbol,

0 commit comments

Comments
 (0)