Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/downstream-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ jobs:
src/composables/__tests__/useModelWhitelist.spec.ts \
src/components/account/__tests__/BulkEditAccountModal.spec.ts \
src/views/admin/__tests__/AccountsView.console.spec.ts \
src/utils/__tests__/accountKeySearch.spec.ts \
src/views/admin/__tests__/AccountsView.priorityColumn.spec.ts \
src/features/image-studio/__tests__/api.spec.ts \
src/components/layout/__tests__/AppSidebar.spec.ts \
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/repository/account_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -1142,7 +1142,7 @@ func (r *accountRepository) accountListFilteredQuery(platform, accountType, stat
}
}
if search != "" {
q = q.Where(dbaccount.NameContainsFold(search))
q = q.Where(service.AccountSearchPredicate(search))
}
if groupID == service.AccountListGroupUngrouped {
q = q.Where(dbaccount.Not(dbaccount.HasAccountGroups()))
Expand Down
41 changes: 41 additions & 0 deletions backend/internal/repository/account_repo_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ package repository

import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -638,6 +640,15 @@ func (s *AccountRepoSuite) TestListWithFilters() {
}
}

func (s *AccountRepoSuite) TestListWithFilters_SearchByAPIKeyDigest() {
requireAccountSearchByAPIKeyDigest(s.T(), s.client, "key-search-", func(search string) []int64 {
accounts, page, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", "", "", search, 0, "")
s.Require().NoError(err)
s.Require().Equal(int64(len(accounts)), page.Total)
return idsOfAccounts(accounts)
})
}

// --- ListByGroup / ListActive / ListByPlatform ---

func (s *AccountRepoSuite) TestListByGroup() {
Expand Down Expand Up @@ -1838,3 +1849,33 @@ func idsOfAccounts(accounts []service.Account) []int64 {
}
return out
}

// accountSearchKeyDigestTerm is the search value the admin page sends in place
// of an API key.
func accountSearchKeyDigestTerm(key string) string {
sum := sha256.Sum256([]byte(key))
return service.AccountSearchKeyDigestPrefix + hex.EncodeToString(sum[:])
}

// requireAccountSearchByAPIKeyDigest seeds accounts named with namePrefix and
// checks one account search path against them; search returns the matched IDs.
func requireAccountSearchByAPIKeyDigest(t *testing.T, client *dbent.Client, namePrefix string, search func(term string) []int64) {
t.Helper()
key := namePrefix + "key"
create := func(name string, credentials map[string]any) int64 {
return mustCreateAccount(t, client, &service.Account{
Name: namePrefix + name, Type: service.AccountTypeAPIKey, Credentials: credentials,
}).ID
}
sharedA := create("shared-a", map[string]any{"api_key": key})
sharedB := create("shared-b", map[string]any{"api_key": key})
padded := create("padded", map[string]any{"api_key": "\t" + key + " \r\n"})
// Named after the key, while its own key merely starts with it.
namedAfterKey := create("key-in-name", map[string]any{"api_key": key + "-other"})
create("number", map[string]any{"api_key": 1234567890})
create("missing", map[string]any{})

require.ElementsMatch(t, []int64{sharedA, sharedB, padded}, search(accountSearchKeyDigestTerm(key)))
require.Empty(t, search(accountSearchKeyDigestTerm("1234567890")), "a non-string api_key is not a key")
require.Equal(t, []int64{namedAfterKey}, search(key), "plain text matches names only")
}
13 changes: 13 additions & 0 deletions backend/internal/repository/account_taxonomy_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,19 @@ func TestAccountTaxonomyCombinedFiltersAndFacetsIntegration(t *testing.T) {
require.Equal(t, 1, integrationTagCount(facets.Tags, tagBlue.ID))
}

func TestAccountTaxonomyConsoleSearchByAPIKeyDigestIntegration(t *testing.T) {
ctx := context.Background()
prefix := taxonomyIntegrationPrefix("key-search")
client, admin := newAccountTaxonomyIntegrationAdmin(t, prefix)

requireAccountSearchByAPIKeyDigest(t, client, prefix, func(search string) []int64 {
accounts, total, err := admin.ListAccountsConsole(ctx, 1, 20, service.AccountConsoleFilters{Search: search})
require.NoError(t, err)
require.Equal(t, int64(len(accounts)), total)
return idsOfAccounts(accounts)
})
}

func TestAccountTaxonomyBulkUpdateAndOrderingIntegration(t *testing.T) {
ctx := context.Background()
prefix := taxonomyIntegrationPrefix("bulk")
Expand Down
64 changes: 64 additions & 0 deletions backend/internal/service/account_search_predicate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package service

import (
"crypto/sha256"
"strings"

dbaccount "github.com/Wei-Shaw/sub2api/ent/account"
dbpredicate "github.com/Wei-Shaw/sub2api/ent/predicate"

entsql "entgo.io/ent/dialect/sql"
)

// AccountSearchKeyDigestPrefix marks an account search value as an API key
// digest term: the prefix followed by the lowercase hex SHA-256 of the key's
// UTF-8 bytes. The admin page sends this term instead of the key, so the
// plaintext key never reaches the server.
const AccountSearchKeyDigestPrefix = "sha256:"

// AccountSearchPredicate is the account search condition shared by every admin
// account query builder, so list, facets, export and bulk-by-filter select the
// same accounts. A key digest term matches the accounts whose
// credentials.api_key hashes to it and never matches names; any other value is
// a case-insensitive substring of the account name.
func AccountSearchPredicate(search string) dbpredicate.Account {
digest, ok := accountSearchKeyDigest(search)
if !ok {
return dbaccount.NameContainsFold(search)
}
return dbpredicate.Account(func(s *entsql.Selector) {
credentials := s.C(dbaccount.FieldCredentials)
s.Where(entsql.And(
// ->> renders any JSON value as text; only a JSON string is a key.
entsql.P(func(b *entsql.Builder) {
b.WriteString("jsonb_typeof(").
Ident(credentials).
WriteString(" -> 'api_key') = 'string'")
}),
// Stored keys are used with surrounding whitespace trimmed, so the
// digest is taken over the trimmed value as well.
entsql.P(func(b *entsql.Builder) {
b.WriteString("encode(sha256(convert_to(btrim(").
Ident(credentials).
WriteString(` ->> 'api_key', E' \t\n\r'), 'UTF8')), 'hex') = `).
Arg(digest)
}),
))
})
}

// accountSearchKeyDigest returns the hex digest carried by a key digest term.
// Only the exact form is a digest term: the prefix followed by 64 lowercase
// hex characters and nothing else.
func accountSearchKeyDigest(search string) (string, bool) {
digest, ok := strings.CutPrefix(search, AccountSearchKeyDigestPrefix)
if !ok || len(digest) != 2*sha256.Size {
return "", false
}
for i := 0; i < len(digest); i++ {
if c := digest[i]; (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return "", false
}
}
return digest, true
}
70 changes: 70 additions & 0 deletions backend/internal/service/account_search_predicate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package service

import (
"crypto/sha256"
"encoding/hex"
"strings"
"testing"

"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
dbaccount "github.com/Wei-Shaw/sub2api/ent/account"
dbpredicate "github.com/Wei-Shaw/sub2api/ent/predicate"
"github.com/stretchr/testify/require"
)

// SHA-256 of the UTF-8 bytes of "sk-test-key", as the admin page computes it.
const accountSearchTestKeyDigest = "0d62f396c1317066f55a96086517047c737087c61eb2bf016b72e6298927b15b"

func accountSearchPredicateSQL(predicates ...dbpredicate.Account) (string, []any) {
selector := entsql.Dialect(dialect.Postgres).Select().From(entsql.Table(dbaccount.Table))
for _, predicate := range predicates {
predicate(selector)
}
return selector.Query()
}

func TestAccountSearchPredicateKeyDigestTermMatchesAPIKeyDigestOnly(t *testing.T) {
sum := sha256.Sum256([]byte("sk-test-key"))
term := AccountSearchKeyDigestPrefix + hex.EncodeToString(sum[:])
require.Equal(t, "sha256:"+accountSearchTestKeyDigest, term)

const keyDigestSQL = `jsonb_typeof("accounts"."credentials" -> 'api_key') = 'string'` +
` AND encode(sha256(convert_to(btrim("accounts"."credentials" ->> 'api_key', E' \t\n\r'), 'UTF8')), 'hex') = `

query, args := accountSearchPredicateSQL(AccountSearchPredicate(term))
require.Equal(t, `SELECT * FROM "accounts" WHERE `+keyDigestSQL+`$1`, query)
require.Equal(t, []any{accountSearchTestKeyDigest}, args)

// Next to another filter the condition stays one AND-ed unit and the digest
// takes the next placeholder.
query, args = accountSearchPredicateSQL(dbaccount.PlatformEQ(PlatformOpenAI), AccountSearchPredicate(term))
require.Equal(t, `SELECT * FROM "accounts" WHERE "accounts"."platform" = $1 AND (`+keyDigestSQL+`$2)`, query)
require.Equal(t, []any{PlatformOpenAI, accountSearchTestKeyDigest}, args)
}

func TestAccountSearchPredicateOtherTermsSearchNameOnly(t *testing.T) {
for _, tc := range []struct {
name string
search string
}{
{"plain text", "Alpha"},
{"plaintext key", "sk-test-key"},
{"uppercase hex", AccountSearchKeyDigestPrefix + strings.ToUpper(accountSearchTestKeyDigest)},
{"63 hex characters", AccountSearchKeyDigestPrefix + accountSearchTestKeyDigest[:63]},
{"65 hex characters", AccountSearchKeyDigestPrefix + accountSearchTestKeyDigest + "0"},
{"non-hex character", AccountSearchKeyDigestPrefix + accountSearchTestKeyDigest[:63] + "g"},
{"uppercase prefix", "SHA256:" + accountSearchTestKeyDigest},
{"leading text", "key " + AccountSearchKeyDigestPrefix + accountSearchTestKeyDigest},
{"trailing text", AccountSearchKeyDigestPrefix + accountSearchTestKeyDigest + " key"},
} {
t.Run(tc.name, func(t *testing.T) {
query, args := accountSearchPredicateSQL(AccountSearchPredicate(tc.search))
require.Equal(t, `SELECT * FROM "accounts" WHERE "accounts"."name" ILIKE $1`, query)

wantQuery, wantArgs := accountSearchPredicateSQL(dbaccount.NameContainsFold(tc.search))
require.Equal(t, wantQuery, query)
require.Equal(t, wantArgs, args)
})
}
}
2 changes: 1 addition & 1 deletion backend/internal/service/admin_account_taxonomy.go
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,7 @@ func (s *adminServiceImpl) accountConsoleQuery(filters AccountConsoleFilters) *d
query = query.Where(dbaccount.TypeIn(values...))
}
if search := strings.TrimSpace(filters.Search); search != "" {
query = query.Where(dbaccount.NameContainsFold(search))
query = query.Where(AccountSearchPredicate(search))
}
if len(filters.AccountIDs) > 0 {
query = query.Where(dbaccount.IDIn(uniquePositiveIDs(filters.AccountIDs)...))
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/api/admin/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ export async function list(
return data
}

/**
* Check whether any account uses the API key behind a digest term
* @param digestTerm - "sha256:<hex>" search term of the key (utils/accountKeySearch); the key itself is not sent
* @returns Whether at least one account has that key, whatever the list is filtered by
*/
export async function hasAPIKeyDigest(digestTerm: string, view?: CapturedAccountView): Promise<boolean> {
const { total } = await list(1, 1, { search: digestTerm, lite: '1' }, undefined, view)
return total > 0
}

export interface AccountListWithEtagResult {
notModified: boolean
etag: string | null
Expand Down Expand Up @@ -1392,6 +1402,7 @@ export async function refreshOpenCodeGoUsage(id: number): Promise<OpenCodeGoUsag

export const accountsAPI = {
list,
hasAPIKeyDigest,
listWithEtag,
getUpstreamBillingRatesWithEtag,
getById,
Expand Down Expand Up @@ -1486,6 +1497,7 @@ export const accountsAPI = {
/** Only fixed native account operations accept an origin-view context. */
const viewArgumentCounts: Partial<Record<keyof typeof accountsAPI, number>> = {
list: 4,
hasAPIKeyDigest: 1,
getUpstreamBillingRatesWithEtag: 4,
listWithEtag: 4,
getById: 1,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/i18n/locales/en/admin/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ export default {accounts: {
crsBack: 'Back',
editAccount: 'Edit Account',
deleteAccount: 'Delete Account',
searchAccounts: 'Search accounts...',
searchAccounts: 'Search name or API key',
notes: 'Notes',
notesPlaceholder: 'Enter notes',
notesHint: 'Notes are optional',
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/i18n/locales/zh/admin/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export default {accounts: {
crsBack: '返回',
editAccount: '编辑账号',
deleteAccount: '删除账号',
searchAccounts: '搜索账号...',
searchAccounts: '搜索名称或 API Key',
notes: '备注',
notesPlaceholder: '请输入备注',
notesHint: '备注可选',
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/styles/console/pages/accounts.css
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ html.flat-theme [data-ui="accounts-toolbar"] > [data-ui="account-search"] {
max-width: 288px;
}

/* At its narrowest (about 155px at 1280) the search leaves its text 111px with the field's 10px right inset, and the
Chinese placeholder is 113px wide: 6px on the right keeps the placeholder whole there. */
html.flat-theme [data-ui="accounts-toolbar"] > [data-ui="account-search"] > input.input[data-ui~="search-field"] {
padding-right: 0.375rem;
}

html.flat-theme [data-ui="accounts-toolbar"] > [data-ui="account-view-switcher"] {
flex: none;
}
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/utils/__tests__/accountKeySearch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { webcrypto } from 'node:crypto'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { accountKeyDigestTerm, isAccountKeyDigestTerm } from '../accountKeySearch'

afterEach(() => { vi.unstubAllGlobals() })

describe('account key search terms', () => {
it('builds the digest term of the trimmed text', async () => {
vi.stubGlobal('crypto', webcrypto)
// SHA-256("abc"), the FIPS 180 example
await expect(accountKeyDigestTerm(' \tabc\r\n')).resolves.toBe(
'sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'
)
})

it('has no digest term for an empty text or without crypto.subtle', async () => {
vi.stubGlobal('crypto', webcrypto)
await expect(accountKeyDigestTerm(' ')).resolves.toBeNull()
vi.stubGlobal('crypto', {})
await expect(accountKeyDigestTerm('abc')).resolves.toBeNull()
})

it('recognises only a complete lowercase digest term', () => {
const term = `sha256:${'0f'.repeat(32)}`
expect(isAccountKeyDigestTerm(term)).toBe(true)
for (const value of [term.toUpperCase(), `sha256:${'0F'.repeat(32)}`, term.slice(0, -1), `${term}0`, ` ${term}`, 'abc', '']) {
expect(isAccountKeyDigestTerm(value)).toBe(false)
}
})
})
19 changes: 19 additions & 0 deletions frontend/src/utils/accountKeySearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// The account search names an upstream API key by a digest term instead of carrying the key: this prefix followed by
// the lowercase hex SHA-256 of the key's UTF-8 bytes.
export const ACCOUNT_KEY_DIGEST_PREFIX = 'sha256:'

const ACCOUNT_KEY_DIGEST_TERM = /^sha256:[0-9a-f]{64}$/

export function isAccountKeyDigestTerm(value: string): boolean {
return ACCOUNT_KEY_DIGEST_TERM.test(value)
}

// Digest term of a search text, surrounding whitespace ignored. Null for an empty text and where crypto.subtle is
// unavailable (insecure context): the text can then only be searched as a name.
export async function accountKeyDigestTerm(text: string): Promise<string | null> {
const trimmed = text.trim()
const subtle = globalThis.crypto?.subtle
if (!trimmed || !subtle) return null
const digest = await subtle.digest('SHA-256', new TextEncoder().encode(trimmed))
return ACCOUNT_KEY_DIGEST_PREFIX + Array.from(new Uint8Array(digest), n => n.toString(16).padStart(2, '0')).join('')
}
Loading
Loading