feat: add ability to name tokens (#6365)

* add tokens switch

* reorged TokensPage

* using Trans component for description

* using Trans component on DeleteDialog

* add owner col

* simplify hook return

* lint

* type for response

* added flag for name

* fixed auth

* lint, prettier, tests

* added unique index for login type token

* remove tokens by name

* better check for unique constraint

* docs

* test: Fix dbfake to insert token name

* fix doc tests

* Update cli/tokens.go

Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>

* Update coderd/database/migrations/000102_add_apikey_name.down.sql

Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>

* add more specificity to IsUniqueViolation check

* fix tests

* Fix AutorizeAllEndpoints

* rename migration

---------

Co-authored-by: Steven Masley <stevenmasley@coder.com>
Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
This commit is contained in:
Kira Pilot
2023-03-02 09:39:38 -08:00
committed by GitHub
co-authored by Steven Masley Steven Masley
parent e3a4861e93
commit 71d1e63af0
37 changed files with 447 additions and 63 deletions
+4
View File
@@ -36,6 +36,10 @@ func (q *querier) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey
return fetch(q.log, q.auth, q.db.GetAPIKeyByID)(ctx, id)
}
func (q *querier) GetAPIKeyByName(ctx context.Context, arg database.GetAPIKeyByNameParams) (database.APIKey, error) {
return fetch(q.log, q.auth, q.db.GetAPIKeyByName)(ctx, arg)
}
func (q *querier) GetAPIKeysByLoginType(ctx context.Context, loginType database.LoginType) ([]database.APIKey, error) {
return fetchWithPostFilter(q.auth, q.db.GetAPIKeysByLoginType)(ctx, loginType)
}
+10
View File
@@ -23,6 +23,16 @@ func (s *MethodTestSuite) TestAPIKey() {
key, _ := dbgen.APIKey(s.T(), db, database.APIKey{})
check.Args(key.ID).Asserts(key, rbac.ActionRead).Returns(key)
}))
s.Run("GetAPIKeyByName", s.Subtest(func(db database.Store, check *expects) {
key, _ := dbgen.APIKey(s.T(), db, database.APIKey{
TokenName: "marge-cat",
LoginType: database.LoginTypeToken,
})
check.Args(database.GetAPIKeyByNameParams{
TokenName: key.TokenName,
UserID: key.UserID,
}).Asserts(key, rbac.ActionRead).Returns(key)
}))
s.Run("GetAPIKeysByLoginType", s.Subtest(func(db database.Store, check *expects) {
a, _ := dbgen.APIKey(s.T(), db, database.APIKey{LoginType: database.LoginTypePassword})
b, _ := dbgen.APIKey(s.T(), db, database.APIKey{LoginType: database.LoginTypePassword})
+16
View File
@@ -464,6 +464,21 @@ func (q *fakeQuerier) GetAPIKeyByID(_ context.Context, id string) (database.APIK
return database.APIKey{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetAPIKeyByName(_ context.Context, params database.GetAPIKeyByNameParams) (database.APIKey, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
if params.TokenName == "" {
return database.APIKey{}, sql.ErrNoRows
}
for _, apiKey := range q.apiKeys {
if params.UserID == apiKey.UserID && params.TokenName == apiKey.TokenName {
return apiKey, nil
}
}
return database.APIKey{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetAPIKeysLastUsedAfter(_ context.Context, after time.Time) ([]database.APIKey, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@@ -2515,6 +2530,7 @@ func (q *fakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyP
LastUsed: arg.LastUsed,
LoginType: arg.LoginType,
Scope: arg.Scope,
TokenName: arg.TokenName,
}
q.apiKeys = append(q.apiKeys, key)
return key, nil
+1
View File
@@ -90,6 +90,7 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey) (key database
UpdatedAt: takeFirst(seed.UpdatedAt, database.Now()),
LoginType: takeFirst(seed.LoginType, database.LoginTypePassword),
Scope: takeFirst(seed.Scope, database.APIKeyScopeAll),
TokenName: takeFirst(seed.TokenName),
})
require.NoError(t, err, "insert api key")
return key, fmt.Sprintf("%s-%s", key.ID, secret)
+4 -1
View File
@@ -134,7 +134,8 @@ CREATE TABLE api_keys (
login_type login_type NOT NULL,
lifetime_seconds bigint DEFAULT 86400 NOT NULL,
ip_address inet DEFAULT '0.0.0.0'::inet NOT NULL,
scope api_key_scope DEFAULT 'all'::api_key_scope NOT NULL
scope api_key_scope DEFAULT 'all'::api_key_scope NOT NULL,
token_name text DEFAULT ''::text NOT NULL
);
COMMENT ON COLUMN api_keys.hashed_secret IS 'hashed_secret contains a SHA256 hash of the key secret. This is considered a secret and MUST NOT be returned from the API as it is used for API key encryption in app proxying code.';
@@ -748,6 +749,8 @@ CREATE INDEX idx_agent_stats_created_at ON workspace_agent_stats USING btree (cr
CREATE INDEX idx_agent_stats_user_id ON workspace_agent_stats USING btree (user_id);
CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type);
CREATE INDEX idx_api_keys_user ON api_keys USING btree (user_id);
CREATE INDEX idx_audit_log_organization_id ON audit_logs USING btree (organization_id);
@@ -0,0 +1,8 @@
BEGIN;
DROP INDEX idx_api_key_name;
ALTER TABLE ONLY api_keys
DROP COLUMN IF EXISTS token_name;
COMMIT;
@@ -0,0 +1,17 @@
BEGIN;
ALTER TABLE ONLY api_keys
ADD COLUMN IF NOT EXISTS token_name text NOT NULL DEFAULT '';
UPDATE
api_keys
SET
token_name = gen_random_uuid ()::text
WHERE
login_type = 'token';
CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name)
WHERE
(login_type = 'token');
COMMIT;
+1
View File
@@ -1216,6 +1216,7 @@ type APIKey struct {
LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"`
IPAddress pqtype.Inet `db:"ip_address" json:"ip_address"`
Scope APIKeyScope `db:"scope" json:"scope"`
TokenName string `db:"token_name" json:"token_name"`
}
type AuditLog struct {
+2
View File
@@ -30,6 +30,8 @@ type sqlcQuerier interface {
DeleteParameterValueByID(ctx context.Context, id uuid.UUID) error
DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error
GetAPIKeyByID(ctx context.Context, id string) (APIKey, error)
// there is no unique constraint on empty token names
GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error)
GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error)
GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error)
GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error)
+53 -6
View File
@@ -43,7 +43,7 @@ func (q *sqlQuerier) DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID
const getAPIKeyByID = `-- name: GetAPIKeyByID :one
SELECT
id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope
id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name
FROM
api_keys
WHERE
@@ -67,12 +67,52 @@ func (q *sqlQuerier) GetAPIKeyByID(ctx context.Context, id string) (APIKey, erro
&i.LifetimeSeconds,
&i.IPAddress,
&i.Scope,
&i.TokenName,
)
return i, err
}
const getAPIKeyByName = `-- name: GetAPIKeyByName :one
SELECT
id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name
FROM
api_keys
WHERE
user_id = $1 AND
token_name = $2 AND
token_name != ''
LIMIT
1
`
type GetAPIKeyByNameParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
TokenName string `db:"token_name" json:"token_name"`
}
// there is no unique constraint on empty token names
func (q *sqlQuerier) GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error) {
row := q.db.QueryRowContext(ctx, getAPIKeyByName, arg.UserID, arg.TokenName)
var i APIKey
err := row.Scan(
&i.ID,
&i.HashedSecret,
&i.UserID,
&i.LastUsed,
&i.ExpiresAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.LoginType,
&i.LifetimeSeconds,
&i.IPAddress,
&i.Scope,
&i.TokenName,
)
return i, err
}
const getAPIKeysByLoginType = `-- name: GetAPIKeysByLoginType :many
SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope FROM api_keys WHERE login_type = $1
SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name FROM api_keys WHERE login_type = $1
`
func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error) {
@@ -96,6 +136,7 @@ func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginT
&i.LifetimeSeconds,
&i.IPAddress,
&i.Scope,
&i.TokenName,
); err != nil {
return nil, err
}
@@ -111,7 +152,7 @@ func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginT
}
const getAPIKeysByUserID = `-- name: GetAPIKeysByUserID :many
SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope FROM api_keys WHERE login_type = $1 AND user_id = $2
SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name FROM api_keys WHERE login_type = $1 AND user_id = $2
`
type GetAPIKeysByUserIDParams struct {
@@ -140,6 +181,7 @@ func (q *sqlQuerier) GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUse
&i.LifetimeSeconds,
&i.IPAddress,
&i.Scope,
&i.TokenName,
); err != nil {
return nil, err
}
@@ -155,7 +197,7 @@ func (q *sqlQuerier) GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUse
}
const getAPIKeysLastUsedAfter = `-- name: GetAPIKeysLastUsedAfter :many
SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope FROM api_keys WHERE last_used > $1
SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name FROM api_keys WHERE last_used > $1
`
func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) {
@@ -179,6 +221,7 @@ func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.
&i.LifetimeSeconds,
&i.IPAddress,
&i.Scope,
&i.TokenName,
); err != nil {
return nil, err
}
@@ -206,7 +249,8 @@ INSERT INTO
created_at,
updated_at,
login_type,
scope
scope,
token_name
)
VALUES
($1,
@@ -215,7 +259,7 @@ VALUES
WHEN 0 THEN 86400
ELSE $2::bigint
END
, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope
, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name
`
type InsertAPIKeyParams struct {
@@ -230,6 +274,7 @@ type InsertAPIKeyParams struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
LoginType LoginType `db:"login_type" json:"login_type"`
Scope APIKeyScope `db:"scope" json:"scope"`
TokenName string `db:"token_name" json:"token_name"`
}
func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) {
@@ -245,6 +290,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (
arg.UpdatedAt,
arg.LoginType,
arg.Scope,
arg.TokenName,
)
var i APIKey
err := row.Scan(
@@ -259,6 +305,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (
&i.LifetimeSeconds,
&i.IPAddress,
&i.Scope,
&i.TokenName,
)
return i, err
}
+16 -2
View File
@@ -8,6 +8,19 @@ WHERE
LIMIT
1;
-- name: GetAPIKeyByName :one
SELECT
*
FROM
api_keys
WHERE
user_id = @user_id AND
token_name = @token_name AND
-- there is no unique constraint on empty token names
token_name != ''
LIMIT
1;
-- name: GetAPIKeysLastUsedAfter :many
SELECT * FROM api_keys WHERE last_used > $1;
@@ -30,7 +43,8 @@ INSERT INTO
created_at,
updated_at,
login_type,
scope
scope,
token_name
)
VALUES
(@id,
@@ -39,7 +53,7 @@ VALUES
WHEN 0 THEN 86400
ELSE @lifetime_seconds::bigint
END
, @hashed_secret, @ip_address, @user_id, @last_used, @expires_at, @created_at, @updated_at, @login_type, @scope) RETURNING *;
, @hashed_secret, @ip_address, @user_id, @last_used, @expires_at, @created_at, @updated_at, @login_type, @scope, @token_name) RETURNING *;
-- name: UpdateAPIKeyByID :exec
UPDATE
+1
View File
@@ -23,6 +23,7 @@ const (
UniqueWorkspaceBuildsJobIDKey UniqueConstraint = "workspace_builds_job_id_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_key UNIQUE (job_id);
UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey UniqueConstraint = "workspace_builds_workspace_id_build_number_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_workspace_id_build_number_key UNIQUE (workspace_id, build_number);
UniqueWorkspaceResourceMetadataName UniqueConstraint = "workspace_resource_metadata_name" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_name UNIQUE (workspace_resource_id, key);
UniqueIndexApiKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type);
UniqueIndexOrganizationName UniqueConstraint = "idx_organization_name" // CREATE UNIQUE INDEX idx_organization_name ON organizations USING btree (name);
UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name));
UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false);