mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
chore: refactor user -> rbac.subject into a function (#13624)
* chore: refactor user subject logic to be in 1 place * test: implement test to assert deleted custom roles are omitted * add unit test for deleted role
This commit is contained in:
@ -406,8 +406,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
|
||||
// If the key is valid, we also fetch the user roles and status.
|
||||
// The roles are used for RBAC authorize checks, and the status
|
||||
// is to block 'suspended' users from accessing the platform.
|
||||
//nolint:gocritic // system needs to update user roles
|
||||
roles, err := cfg.DB.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), key.UserID)
|
||||
actor, userStatus, err := UserRBACSubject(ctx, cfg.DB, key.UserID, rbac.ScopeName(key.Scope))
|
||||
if err != nil {
|
||||
return write(http.StatusUnauthorized, codersdk.Response{
|
||||
Message: internalErrorMessage,
|
||||
@ -415,7 +414,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
|
||||
})
|
||||
}
|
||||
|
||||
if roles.Status == database.UserStatusDormant {
|
||||
if userStatus == database.UserStatusDormant {
|
||||
// If coder confirms that the dormant user is valid, it can switch their account to active.
|
||||
// nolint:gocritic
|
||||
u, err := cfg.DB.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{
|
||||
@ -429,42 +428,15 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
|
||||
Detail: fmt.Sprintf("can't activate a dormant user: %s", err.Error()),
|
||||
})
|
||||
}
|
||||
roles.Status = u.Status
|
||||
userStatus = u.Status
|
||||
}
|
||||
|
||||
if roles.Status != database.UserStatusActive {
|
||||
if userStatus != database.UserStatusActive {
|
||||
return write(http.StatusUnauthorized, codersdk.Response{
|
||||
Message: fmt.Sprintf("User is not active (status = %q). Contact an admin to reactivate your account.", roles.Status),
|
||||
Message: fmt.Sprintf("User is not active (status = %q). Contact an admin to reactivate your account.", userStatus),
|
||||
})
|
||||
}
|
||||
|
||||
roleNames, err := roles.RoleNames()
|
||||
if err != nil {
|
||||
return write(http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal Server Error",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:gocritic // Permission to lookup custom roles the user has assigned.
|
||||
rbacRoles, err := rolestore.Expand(dbauthz.AsSystemRestricted(ctx), cfg.DB, roleNames)
|
||||
if err != nil {
|
||||
return write(http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to expand authenticated user roles",
|
||||
Detail: err.Error(),
|
||||
Validations: nil,
|
||||
})
|
||||
}
|
||||
|
||||
// Actor is the user's authorization context.
|
||||
actor := rbac.Subject{
|
||||
FriendlyName: roles.Username,
|
||||
ID: key.UserID.String(),
|
||||
Roles: rbacRoles,
|
||||
Groups: roles.Groups,
|
||||
Scope: rbac.ScopeName(key.Scope),
|
||||
}.WithCachedASTValue()
|
||||
|
||||
if cfg.PostAuthAdditionalHeadersFunc != nil {
|
||||
cfg.PostAuthAdditionalHeadersFunc(actor, rw.Header())
|
||||
}
|
||||
@ -472,6 +444,36 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
|
||||
return key, &actor, true
|
||||
}
|
||||
|
||||
// UserRBACSubject fetches a user's rbac.Subject from the database. It pulls all roles from both
|
||||
// site and organization scopes. It also pulls the groups, and the user's status.
|
||||
func UserRBACSubject(ctx context.Context, db database.Store, userID uuid.UUID, scope rbac.ExpandableScope) (rbac.Subject, database.UserStatus, error) {
|
||||
//nolint:gocritic // system needs to update user roles
|
||||
roles, err := db.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), userID)
|
||||
if err != nil {
|
||||
return rbac.Subject{}, "", xerrors.Errorf("get authorization user roles: %w", err)
|
||||
}
|
||||
|
||||
roleNames, err := roles.RoleNames()
|
||||
if err != nil {
|
||||
return rbac.Subject{}, "", xerrors.Errorf("expand role names: %w", err)
|
||||
}
|
||||
|
||||
//nolint:gocritic // Permission to lookup custom roles the user has assigned.
|
||||
rbacRoles, err := rolestore.Expand(dbauthz.AsSystemRestricted(ctx), db, roleNames)
|
||||
if err != nil {
|
||||
return rbac.Subject{}, "", xerrors.Errorf("expand role names: %w", err)
|
||||
}
|
||||
|
||||
actor := rbac.Subject{
|
||||
FriendlyName: roles.Username,
|
||||
ID: userID.String(),
|
||||
Roles: rbacRoles,
|
||||
Groups: roles.Groups,
|
||||
Scope: scope,
|
||||
}.WithCachedASTValue()
|
||||
return actor, roles.Status, nil
|
||||
}
|
||||
|
||||
// APITokenFromRequest returns the api token from the request.
|
||||
// Find the session token from:
|
||||
// 1: The cookie
|
||||
|
@ -14,16 +14,20 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmem"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
@ -38,6 +42,37 @@ func randomAPIKeyParts() (id string, secret string) {
|
||||
func TestAPIKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// assertActorOk asserts all the properties of the user auth are ok.
|
||||
assertActorOk := func(t *testing.T, r *http.Request) {
|
||||
t.Helper()
|
||||
|
||||
actor, ok := dbauthz.ActorFromContext(r.Context())
|
||||
assert.True(t, ok, "dbauthz actor ok")
|
||||
if ok {
|
||||
_, err := actor.Roles.Expand()
|
||||
assert.NoError(t, err, "actor roles ok")
|
||||
|
||||
_, err = actor.Scope.Expand()
|
||||
assert.NoError(t, err, "actor scope ok")
|
||||
|
||||
err = actor.RegoValueOk()
|
||||
assert.NoError(t, err, "actor rego ok")
|
||||
}
|
||||
|
||||
auth, ok := httpmw.UserAuthorizationOptional(r)
|
||||
assert.True(t, ok, "httpmw auth ok")
|
||||
if ok {
|
||||
_, err := auth.Roles.Expand()
|
||||
assert.NoError(t, err, "auth roles ok")
|
||||
|
||||
_, err = auth.Scope.Expand()
|
||||
assert.NoError(t, err, "auth scope ok")
|
||||
|
||||
err = auth.RegoValueOk()
|
||||
assert.NoError(t, err, "auth rego ok")
|
||||
}
|
||||
}
|
||||
|
||||
successHandler := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
// Only called if the API key passes through the handler.
|
||||
httpapi.Write(context.Background(), rw, http.StatusOK, codersdk.Response{
|
||||
@ -256,6 +291,7 @@ func TestAPIKey(t *testing.T) {
|
||||
})(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
// Checks that it exists on the context!
|
||||
_ = httpmw.APIKey(r)
|
||||
assertActorOk(t, r)
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
|
||||
Message: "It worked!",
|
||||
})
|
||||
@ -296,6 +332,7 @@ func TestAPIKey(t *testing.T) {
|
||||
// Checks that it exists on the context!
|
||||
apiKey := httpmw.APIKey(r)
|
||||
assert.Equal(t, database.APIKeyScopeApplicationConnect, apiKey.Scope)
|
||||
assertActorOk(t, r)
|
||||
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
|
||||
Message: "it worked!",
|
||||
@ -330,6 +367,8 @@ func TestAPIKey(t *testing.T) {
|
||||
})(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
// Checks that it exists on the context!
|
||||
_ = httpmw.APIKey(r)
|
||||
assertActorOk(t, r)
|
||||
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
|
||||
Message: "It worked!",
|
||||
})
|
||||
@ -633,7 +672,7 @@ func TestAPIKey(t *testing.T) {
|
||||
require.Equal(t, sentAPIKey.LoginType, gotAPIKey.LoginType)
|
||||
})
|
||||
|
||||
t.Run("MissongConfig", func(t *testing.T) {
|
||||
t.Run("MissingConfig", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
db = dbmem.New()
|
||||
@ -667,4 +706,133 @@ func TestAPIKey(t *testing.T) {
|
||||
out, _ := io.ReadAll(res.Body)
|
||||
require.Contains(t, string(out), "Unable to refresh")
|
||||
})
|
||||
|
||||
t.Run("CustomRoles", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
db = dbmem.New()
|
||||
org = dbgen.Organization(t, db, database.Organization{})
|
||||
customRole = dbgen.CustomRole(t, db, database.CustomRole{
|
||||
Name: "custom-role",
|
||||
OrgPermissions: []database.CustomRolePermission{},
|
||||
OrganizationID: uuid.NullUUID{
|
||||
UUID: org.ID,
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
user = dbgen.User(t, db, database.User{
|
||||
RBACRoles: []string{},
|
||||
})
|
||||
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
UserID: user.ID,
|
||||
OrganizationID: org.ID,
|
||||
CreatedAt: time.Time{},
|
||||
UpdatedAt: time.Time{},
|
||||
Roles: []string{
|
||||
rbac.RoleOrgAdmin(),
|
||||
customRole.Name,
|
||||
},
|
||||
})
|
||||
_, token = dbgen.APIKey(t, db, database.APIKey{
|
||||
UserID: user.ID,
|
||||
ExpiresAt: dbtime.Now().AddDate(0, 0, 1),
|
||||
})
|
||||
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
rw = httptest.NewRecorder()
|
||||
)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, token)
|
||||
|
||||
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
|
||||
DB: db,
|
||||
RedirectToLogin: false,
|
||||
})(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
assertActorOk(t, r)
|
||||
|
||||
auth := httpmw.UserAuthorization(r)
|
||||
|
||||
roles, err := auth.Roles.Expand()
|
||||
assert.NoError(t, err, "expand user roles")
|
||||
// Assert built in org role
|
||||
assert.True(t, slices.ContainsFunc(roles, func(role rbac.Role) bool {
|
||||
return role.Identifier.Name == rbac.RoleOrgAdmin() && role.Identifier.OrganizationID == org.ID
|
||||
}), "org admin role")
|
||||
// Assert custom role
|
||||
assert.True(t, slices.ContainsFunc(roles, func(role rbac.Role) bool {
|
||||
return role.Identifier.Name == customRole.Name && role.Identifier.OrganizationID == org.ID
|
||||
}), "custom org role")
|
||||
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
|
||||
Message: "It worked!",
|
||||
})
|
||||
})).ServeHTTP(rw, r)
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
})
|
||||
|
||||
// There is no sql foreign key constraint to require all assigned roles
|
||||
// still exist in the database. We need to handle deleted roles.
|
||||
t.Run("RoleNotExists", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
roleNotExistsName = "role-not-exists"
|
||||
db = dbmem.New()
|
||||
org = dbgen.Organization(t, db, database.Organization{})
|
||||
user = dbgen.User(t, db, database.User{
|
||||
RBACRoles: []string{
|
||||
// Also provide an org not exists. In practice this makes no sense
|
||||
// to store org roles in the user table, but there is no org to
|
||||
// store it in. So just throw this here for even more unexpected
|
||||
// behavior handling!
|
||||
rbac.RoleIdentifier{Name: roleNotExistsName, OrganizationID: uuid.New()}.String(),
|
||||
},
|
||||
})
|
||||
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
UserID: user.ID,
|
||||
OrganizationID: org.ID,
|
||||
CreatedAt: time.Time{},
|
||||
UpdatedAt: time.Time{},
|
||||
Roles: []string{
|
||||
rbac.RoleOrgAdmin(),
|
||||
roleNotExistsName,
|
||||
},
|
||||
})
|
||||
_, token = dbgen.APIKey(t, db, database.APIKey{
|
||||
UserID: user.ID,
|
||||
ExpiresAt: dbtime.Now().AddDate(0, 0, 1),
|
||||
})
|
||||
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
rw = httptest.NewRecorder()
|
||||
)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, token)
|
||||
|
||||
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
|
||||
DB: db,
|
||||
RedirectToLogin: false,
|
||||
})(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
assertActorOk(t, r)
|
||||
auth := httpmw.UserAuthorization(r)
|
||||
|
||||
roles, err := auth.Roles.Expand()
|
||||
assert.NoError(t, err, "expand user roles")
|
||||
// Assert built in org role
|
||||
assert.True(t, slices.ContainsFunc(roles, func(role rbac.Role) bool {
|
||||
return role.Identifier.Name == rbac.RoleOrgAdmin() && role.Identifier.OrganizationID == org.ID
|
||||
}), "org admin role")
|
||||
|
||||
// Assert the role-not-exists is not returned
|
||||
assert.False(t, slices.ContainsFunc(roles, func(role rbac.Role) bool {
|
||||
return role.Identifier.Name == roleNotExistsName
|
||||
}), "role should not exist")
|
||||
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
|
||||
Message: "It worked!",
|
||||
})
|
||||
})).ServeHTTP(rw, r)
|
||||
res := rw.Result()
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
})
|
||||
}
|
||||
|
Reference in New Issue
Block a user