chore: implement user link claims as a typed golang object (#15502)

Move claims from a `debug` column to an actual typed column to be used.
This does not functionally change anything, it just adds some Go typing to build
on.
This commit is contained in:
Steven Masley
2024-11-14 10:05:44 -06:00
committed by GitHub
parent 6b1fafbacc
commit b6d0b7713a
19 changed files with 105 additions and 65 deletions
+1 -2
View File
@@ -3,7 +3,6 @@ package oidctest
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/url"
"testing"
@@ -89,7 +88,7 @@ func (*LoginHelper) ExpireOauthToken(t *testing.T, db database.Store, user *code
OAuthExpiry: time.Now().Add(time.Hour * -1),
UserID: link.UserID,
LoginType: link.LoginType,
DebugContext: json.RawMessage("{}"),
Claims: database.UserLinkClaims{},
})
require.NoError(t, err, "expire user link")
+1 -1
View File
@@ -1281,7 +1281,7 @@ func (s *MethodTestSuite) TestUser() {
OAuthExpiry: link.OAuthExpiry,
UserID: link.UserID,
LoginType: link.LoginType,
DebugContext: json.RawMessage("{}"),
Claims: database.UserLinkClaims{},
}).Asserts(rbac.ResourceUserObject(link.UserID), policy.ActionUpdatePersonal).Returns(link)
}))
s.Run("UpdateUserRoles", s.Subtest(func(db database.Store, check *expects) {
+1 -1
View File
@@ -726,7 +726,7 @@ func UserLink(t testing.TB, db database.Store, orig database.UserLink) database.
OAuthRefreshToken: takeFirst(orig.OAuthRefreshToken, uuid.NewString()),
OAuthRefreshTokenKeyID: takeFirst(orig.OAuthRefreshTokenKeyID, sql.NullString{}),
OAuthExpiry: takeFirst(orig.OAuthExpiry, dbtime.Now().Add(time.Hour*24)),
DebugContext: takeFirstSlice(orig.DebugContext, json.RawMessage("{}")),
Claims: orig.Claims,
})
require.NoError(t, err, "insert link")
+2 -2
View File
@@ -7857,7 +7857,7 @@ func (q *FakeQuerier) InsertUserLink(_ context.Context, args database.InsertUser
OAuthRefreshToken: args.OAuthRefreshToken,
OAuthRefreshTokenKeyID: args.OAuthRefreshTokenKeyID,
OAuthExpiry: args.OAuthExpiry,
DebugContext: args.DebugContext,
Claims: args.Claims,
}
q.userLinks = append(q.userLinks, link)
@@ -9318,7 +9318,7 @@ func (q *FakeQuerier) UpdateUserLink(_ context.Context, params database.UpdateUs
link.OAuthRefreshToken = params.OAuthRefreshToken
link.OAuthRefreshTokenKeyID = params.OAuthRefreshTokenKeyID
link.OAuthExpiry = params.OAuthExpiry
link.DebugContext = params.DebugContext
link.Claims = params.Claims
q.userLinks[i] = link
return link, nil
+2 -2
View File
@@ -1337,14 +1337,14 @@ CREATE TABLE user_links (
oauth_expiry timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL,
oauth_access_token_key_id text,
oauth_refresh_token_key_id text,
debug_context jsonb DEFAULT '{}'::jsonb NOT NULL
claims jsonb DEFAULT '{}'::jsonb NOT NULL
);
COMMENT ON COLUMN user_links.oauth_access_token_key_id IS 'The ID of the key used to encrypt the OAuth access token. If this is NULL, the access token is not encrypted';
COMMENT ON COLUMN user_links.oauth_refresh_token_key_id IS 'The ID of the key used to encrypt the OAuth refresh token. If this is NULL, the refresh token is not encrypted';
COMMENT ON COLUMN user_links.debug_context IS 'Debug information includes information like id_token and userinfo claims.';
COMMENT ON COLUMN user_links.claims IS 'Claims from the IDP for the linked user. Includes both id_token and userinfo claims. ';
CREATE TABLE workspace_agent_log_sources (
workspace_agent_id uuid NOT NULL,
@@ -0,0 +1,3 @@
ALTER TABLE user_links RENAME COLUMN claims TO debug_context;
COMMENT ON COLUMN user_links.debug_context IS 'Debug information includes information like id_token and userinfo claims.';
@@ -0,0 +1,3 @@
ALTER TABLE user_links RENAME COLUMN debug_context TO claims;
COMMENT ON COLUMN user_links.claims IS 'Claims from the IDP for the linked user. Includes both id_token and userinfo claims. ';
+2 -2
View File
@@ -2892,8 +2892,8 @@ type UserLink struct {
OAuthAccessTokenKeyID sql.NullString `db:"oauth_access_token_key_id" json:"oauth_access_token_key_id"`
// The ID of the key used to encrypt the OAuth refresh token. If this is NULL, the refresh token is not encrypted
OAuthRefreshTokenKeyID sql.NullString `db:"oauth_refresh_token_key_id" json:"oauth_refresh_token_key_id"`
// Debug information includes information like id_token and userinfo claims.
DebugContext json.RawMessage `db:"debug_context" json:"debug_context"`
// Claims from the IDP for the linked user. Includes both id_token and userinfo claims.
Claims UserLinkClaims `db:"claims" json:"claims"`
}
// Visible fields of users are allowed to be joined with other tables for including context of other resources.
+33 -33
View File
@@ -9689,7 +9689,7 @@ func (q *sqlQuerier) InsertTemplateVersionWorkspaceTag(ctx context.Context, arg
const getUserLinkByLinkedID = `-- name: GetUserLinkByLinkedID :one
SELECT
user_links.user_id, user_links.login_type, user_links.linked_id, user_links.oauth_access_token, user_links.oauth_refresh_token, user_links.oauth_expiry, user_links.oauth_access_token_key_id, user_links.oauth_refresh_token_key_id, user_links.debug_context
user_links.user_id, user_links.login_type, user_links.linked_id, user_links.oauth_access_token, user_links.oauth_refresh_token, user_links.oauth_expiry, user_links.oauth_access_token_key_id, user_links.oauth_refresh_token_key_id, user_links.claims
FROM
user_links
INNER JOIN
@@ -9712,14 +9712,14 @@ func (q *sqlQuerier) GetUserLinkByLinkedID(ctx context.Context, linkedID string)
&i.OAuthExpiry,
&i.OAuthAccessTokenKeyID,
&i.OAuthRefreshTokenKeyID,
&i.DebugContext,
&i.Claims,
)
return i, err
}
const getUserLinkByUserIDLoginType = `-- name: GetUserLinkByUserIDLoginType :one
SELECT
user_id, login_type, linked_id, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, debug_context
user_id, login_type, linked_id, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, claims
FROM
user_links
WHERE
@@ -9743,13 +9743,13 @@ func (q *sqlQuerier) GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUs
&i.OAuthExpiry,
&i.OAuthAccessTokenKeyID,
&i.OAuthRefreshTokenKeyID,
&i.DebugContext,
&i.Claims,
)
return i, err
}
const getUserLinksByUserID = `-- name: GetUserLinksByUserID :many
SELECT user_id, login_type, linked_id, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, debug_context FROM user_links WHERE user_id = $1
SELECT user_id, login_type, linked_id, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, claims FROM user_links WHERE user_id = $1
`
func (q *sqlQuerier) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]UserLink, error) {
@@ -9770,7 +9770,7 @@ func (q *sqlQuerier) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID)
&i.OAuthExpiry,
&i.OAuthAccessTokenKeyID,
&i.OAuthRefreshTokenKeyID,
&i.DebugContext,
&i.Claims,
); err != nil {
return nil, err
}
@@ -9796,22 +9796,22 @@ INSERT INTO
oauth_refresh_token,
oauth_refresh_token_key_id,
oauth_expiry,
debug_context
claims
)
VALUES
( $1, $2, $3, $4, $5, $6, $7, $8, $9 ) RETURNING user_id, login_type, linked_id, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, debug_context
( $1, $2, $3, $4, $5, $6, $7, $8, $9 ) RETURNING user_id, login_type, linked_id, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, claims
`
type InsertUserLinkParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
LoginType LoginType `db:"login_type" json:"login_type"`
LinkedID string `db:"linked_id" json:"linked_id"`
OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"`
OAuthAccessTokenKeyID sql.NullString `db:"oauth_access_token_key_id" json:"oauth_access_token_key_id"`
OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
OAuthRefreshTokenKeyID sql.NullString `db:"oauth_refresh_token_key_id" json:"oauth_refresh_token_key_id"`
OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"`
DebugContext json.RawMessage `db:"debug_context" json:"debug_context"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
LoginType LoginType `db:"login_type" json:"login_type"`
LinkedID string `db:"linked_id" json:"linked_id"`
OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"`
OAuthAccessTokenKeyID sql.NullString `db:"oauth_access_token_key_id" json:"oauth_access_token_key_id"`
OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
OAuthRefreshTokenKeyID sql.NullString `db:"oauth_refresh_token_key_id" json:"oauth_refresh_token_key_id"`
OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"`
Claims UserLinkClaims `db:"claims" json:"claims"`
}
func (q *sqlQuerier) InsertUserLink(ctx context.Context, arg InsertUserLinkParams) (UserLink, error) {
@@ -9824,7 +9824,7 @@ func (q *sqlQuerier) InsertUserLink(ctx context.Context, arg InsertUserLinkParam
arg.OAuthRefreshToken,
arg.OAuthRefreshTokenKeyID,
arg.OAuthExpiry,
arg.DebugContext,
arg.Claims,
)
var i UserLink
err := row.Scan(
@@ -9836,7 +9836,7 @@ func (q *sqlQuerier) InsertUserLink(ctx context.Context, arg InsertUserLinkParam
&i.OAuthExpiry,
&i.OAuthAccessTokenKeyID,
&i.OAuthRefreshTokenKeyID,
&i.DebugContext,
&i.Claims,
)
return i, err
}
@@ -9850,20 +9850,20 @@ SET
oauth_refresh_token = $3,
oauth_refresh_token_key_id = $4,
oauth_expiry = $5,
debug_context = $6
claims = $6
WHERE
user_id = $7 AND login_type = $8 RETURNING user_id, login_type, linked_id, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, debug_context
user_id = $7 AND login_type = $8 RETURNING user_id, login_type, linked_id, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, claims
`
type UpdateUserLinkParams struct {
OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"`
OAuthAccessTokenKeyID sql.NullString `db:"oauth_access_token_key_id" json:"oauth_access_token_key_id"`
OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
OAuthRefreshTokenKeyID sql.NullString `db:"oauth_refresh_token_key_id" json:"oauth_refresh_token_key_id"`
OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"`
DebugContext json.RawMessage `db:"debug_context" json:"debug_context"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
LoginType LoginType `db:"login_type" json:"login_type"`
OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"`
OAuthAccessTokenKeyID sql.NullString `db:"oauth_access_token_key_id" json:"oauth_access_token_key_id"`
OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
OAuthRefreshTokenKeyID sql.NullString `db:"oauth_refresh_token_key_id" json:"oauth_refresh_token_key_id"`
OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"`
Claims UserLinkClaims `db:"claims" json:"claims"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
LoginType LoginType `db:"login_type" json:"login_type"`
}
func (q *sqlQuerier) UpdateUserLink(ctx context.Context, arg UpdateUserLinkParams) (UserLink, error) {
@@ -9873,7 +9873,7 @@ func (q *sqlQuerier) UpdateUserLink(ctx context.Context, arg UpdateUserLinkParam
arg.OAuthRefreshToken,
arg.OAuthRefreshTokenKeyID,
arg.OAuthExpiry,
arg.DebugContext,
arg.Claims,
arg.UserID,
arg.LoginType,
)
@@ -9887,7 +9887,7 @@ func (q *sqlQuerier) UpdateUserLink(ctx context.Context, arg UpdateUserLinkParam
&i.OAuthExpiry,
&i.OAuthAccessTokenKeyID,
&i.OAuthRefreshTokenKeyID,
&i.DebugContext,
&i.Claims,
)
return i, err
}
@@ -9898,7 +9898,7 @@ UPDATE
SET
linked_id = $1
WHERE
user_id = $2 AND login_type = $3 RETURNING user_id, login_type, linked_id, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, debug_context
user_id = $2 AND login_type = $3 RETURNING user_id, login_type, linked_id, oauth_access_token, oauth_refresh_token, oauth_expiry, oauth_access_token_key_id, oauth_refresh_token_key_id, claims
`
type UpdateUserLinkedIDParams struct {
@@ -9919,7 +9919,7 @@ func (q *sqlQuerier) UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinke
&i.OAuthExpiry,
&i.OAuthAccessTokenKeyID,
&i.OAuthRefreshTokenKeyID,
&i.DebugContext,
&i.Claims,
)
return i, err
}
+2 -2
View File
@@ -32,7 +32,7 @@ INSERT INTO
oauth_refresh_token,
oauth_refresh_token_key_id,
oauth_expiry,
debug_context
claims
)
VALUES
( $1, $2, $3, $4, $5, $6, $7, $8, $9 ) RETURNING *;
@@ -54,6 +54,6 @@ SET
oauth_refresh_token = $3,
oauth_refresh_token_key_id = $4,
oauth_expiry = $5,
debug_context = $6
claims = $6
WHERE
user_id = $7 AND login_type = $8 RETURNING *;
+3
View File
@@ -79,6 +79,9 @@ sql:
- column: "provisioner_job_stats.*_secs"
go_type:
type: "float64"
- column: "user_links.claims"
go_type:
type: "UserLinkClaims"
rename:
group_member: GroupMemberTable
group_members_expanded: GroupMember
+22
View File
@@ -207,3 +207,25 @@ func (p *AgentIDNamePair) Scan(src interface{}) error {
func (p AgentIDNamePair) Value() (driver.Value, error) {
return fmt.Sprintf(`(%s,%s)`, p.ID.String(), p.Name), nil
}
// UserLinkClaims is the returned IDP claims for a given user link.
// These claims are fetched at login time. These are the claims that were
// used for IDP sync.
type UserLinkClaims struct {
IDTokenClaims map[string]interface{} `json:"id_token_claims"`
UserInfoClaims map[string]interface{} `json:"user_info_claims"`
}
func (a *UserLinkClaims) Scan(src interface{}) error {
switch v := src.(type) {
case string:
return json.Unmarshal([]byte(v), &a)
case []byte:
return json.Unmarshal(v, &a)
}
return xerrors.Errorf("unexpected type %T", src)
}
func (a UserLinkClaims) Value() (driver.Value, error) {
return json.Marshal(a)
}
+1 -1
View File
@@ -377,7 +377,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
OAuthExpiry: link.OAuthExpiry,
// Refresh should keep the same debug context because we use
// the original claims for the group/role sync.
DebugContext: link.DebugContext,
Claims: link.Claims,
})
if err != nil {
return write(http.StatusInternalServerError, codersdk.Response{
@@ -2083,7 +2083,7 @@ func obtainOIDCAccessToken(ctx context.Context, db database.Store, oidcConfig pr
OAuthRefreshToken: link.OAuthRefreshToken,
OAuthRefreshTokenKeyID: sql.NullString{}, // set by dbcrypt if required
OAuthExpiry: link.OAuthExpiry,
DebugContext: link.DebugContext,
Claims: link.Claims,
})
if err != nil {
return "", xerrors.Errorf("update user link: %w", err)
+7 -11
View File
@@ -3,7 +3,6 @@ package coderd
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -966,7 +965,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
Username: username,
AvatarURL: ghUser.GetAvatarURL(),
Name: normName,
DebugContext: OauthDebugContext{},
UserClaims: database.UserLinkClaims{},
GroupSync: idpsync.GroupParams{
SyncEntitled: false,
},
@@ -1324,7 +1323,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
OrganizationSync: orgSync,
GroupSync: groupSync,
RoleSync: roleSync,
DebugContext: OauthDebugContext{
UserClaims: database.UserLinkClaims{
IDTokenClaims: idtokenClaims,
UserInfoClaims: userInfoClaims,
},
@@ -1421,7 +1420,9 @@ type oauthLoginParams struct {
GroupSync idpsync.GroupParams
RoleSync idpsync.RoleParams
DebugContext OauthDebugContext
// UserClaims should only be populated for OIDC logins.
// It is used to save the user's claims on login.
UserClaims database.UserLinkClaims
commitLock sync.Mutex
initAuditRequest func(params *audit.RequestParams) *audit.Request[database.User]
@@ -1591,11 +1592,6 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
dormantConvertAudit.New = user
}
debugContext, err := json.Marshal(params.DebugContext)
if err != nil {
return xerrors.Errorf("marshal debug context: %w", err)
}
if link.UserID == uuid.Nil {
//nolint:gocritic // System needs to insert the user link (linked_id, oauth_token, oauth_expiry).
link, err = tx.InsertUserLink(dbauthz.AsSystemRestricted(ctx), database.InsertUserLinkParams{
@@ -1607,7 +1603,7 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
OAuthRefreshToken: params.State.Token.RefreshToken,
OAuthRefreshTokenKeyID: sql.NullString{}, // set by dbcrypt if required
OAuthExpiry: params.State.Token.Expiry,
DebugContext: debugContext,
Claims: params.UserClaims,
})
if err != nil {
return xerrors.Errorf("insert user link: %w", err)
@@ -1624,7 +1620,7 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
OAuthRefreshToken: params.State.Token.RefreshToken,
OAuthRefreshTokenKeyID: sql.NullString{}, // set by dbcrypt if required
OAuthExpiry: params.State.Token.Expiry,
DebugContext: debugContext,
Claims: params.UserClaims,
})
if err != nil {
return xerrors.Errorf("update user link: %w", err)
+1 -1
View File
@@ -843,7 +843,7 @@ func TestUserOAuth2Github(t *testing.T) {
OAuthAccessToken: "random",
OAuthRefreshToken: "random",
OAuthExpiry: time.Now(),
DebugContext: []byte(`{}`),
Claims: database.UserLinkClaims{},
})
require.ErrorContains(t, err, "Cannot create user_link for deleted user")
+1 -2
View File
@@ -70,8 +70,7 @@ func (api *API) userDebugOIDC(rw http.ResponseWriter, r *http.Request) {
return
}
// This will encode properly because it is a json.RawMessage.
httpapi.Write(ctx, rw, http.StatusOK, link.DebugContext)
httpapi.Write(ctx, rw, http.StatusOK, link.Claims)
}
// Returns whether the initial user has been created or not.
+2 -2
View File
@@ -43,7 +43,7 @@ func Rotate(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Ciphe
OAuthExpiry: userLink.OAuthExpiry,
UserID: uid,
LoginType: userLink.LoginType,
DebugContext: userLink.DebugContext,
Claims: userLink.Claims,
}); err != nil {
return xerrors.Errorf("update user link user_id=%s linked_id=%s: %w", userLink.UserID, userLink.LinkedID, err)
}
@@ -133,7 +133,7 @@ func Decrypt(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Ciph
OAuthExpiry: userLink.OAuthExpiry,
UserID: uid,
LoginType: userLink.LoginType,
DebugContext: userLink.DebugContext,
Claims: userLink.Claims,
}); err != nil {
return xerrors.Errorf("update user link user_id=%s linked_id=%s: %w", userLink.UserID, userLink.LinkedID, err)
}
+17 -2
View File
@@ -5,7 +5,6 @@ import (
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"io"
"testing"
"time"
@@ -52,12 +51,27 @@ func TestUserLinks(t *testing.T) {
UserID: user.ID,
})
expectedClaims := database.UserLinkClaims{
IDTokenClaims: map[string]interface{}{
"sub": "123",
"groups": []interface{}{
"foo", "bar",
},
},
UserInfoClaims: map[string]interface{}{
"number": float64(2),
"struct": map[string]interface{}{
"number": float64(2),
},
},
}
updated, err := crypt.UpdateUserLink(ctx, database.UpdateUserLinkParams{
OAuthAccessToken: "access",
OAuthRefreshToken: "refresh",
UserID: link.UserID,
LoginType: link.LoginType,
DebugContext: json.RawMessage("{}"),
Claims: expectedClaims,
})
require.NoError(t, err)
require.Equal(t, "access", updated.OAuthAccessToken)
@@ -69,6 +83,7 @@ func TestUserLinks(t *testing.T) {
require.NoError(t, err)
requireEncryptedEquals(t, ciphers[0], rawLink.OAuthAccessToken, "access")
requireEncryptedEquals(t, ciphers[0], rawLink.OAuthRefreshToken, "refresh")
require.EqualValues(t, expectedClaims, rawLink.Claims)
})
t.Run("GetUserLinkByLinkedID", func(t *testing.T) {