0.29.1原版
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
## Task List
|
||||
|
||||
**Task Index**
|
||||
|
||||
> T1: Add `user_identity` migrations + LATEST.sql updates for all three backends [M] — T2: Add `store.UserIdentity` model and `Store` methods + driver interface [M] — T3: Implement SQLite driver for `user_identity` [M] — T4: Implement Postgres driver for `user_identity` [M] — T5: Implement MySQL driver for `user_identity` [M] — T6: Add store-layer tests for `user_identity` [M] — T7: Add SSO username derivation helper [M] — T8: Route SSO sign-in through `user_identity` linkage [L]
|
||||
|
||||
### T1: Add `user_identity` migrations + LATEST.sql updates [M]
|
||||
|
||||
**Objective**: Create the `user_identity` persistence structure across SQLite, Postgres, and MySQL, and reflect it in `LATEST.sql` for fresh installs (G1, G2, G3, G4, G5; design §1, §5).
|
||||
|
||||
**Size**: M (3 new migration files, 3 LATEST.sql edits; straightforward DDL).
|
||||
|
||||
**Files**:
|
||||
- Create: `store/migration/sqlite/0.28/00__user_identity.sql`
|
||||
- Create: `store/migration/postgres/0.28/00__user_identity.sql`
|
||||
- Create: `store/migration/mysql/0.28/00__user_identity.sql`
|
||||
- Modify: `store/migration/sqlite/LATEST.sql`
|
||||
- Modify: `store/migration/postgres/LATEST.sql`
|
||||
- Modify: `store/migration/mysql/LATEST.sql`
|
||||
|
||||
**Implementation**:
|
||||
1. `store/migration/sqlite/0.28/00__user_identity.sql`:
|
||||
```sql
|
||||
CREATE TABLE user_identity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
extern_uid TEXT NOT NULL,
|
||||
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
UNIQUE (provider, extern_uid)
|
||||
);
|
||||
CREATE INDEX idx_user_identity_user_id ON user_identity(user_id);
|
||||
```
|
||||
2. `store/migration/postgres/0.28/00__user_identity.sql`: same logical schema with Postgres types — `id SERIAL PRIMARY KEY`, `user_id INTEGER NOT NULL`, `provider TEXT NOT NULL`, `extern_uid TEXT NOT NULL`, `created_ts BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())`, `updated_ts BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())`, `UNIQUE(provider, extern_uid)`, plus `CREATE INDEX idx_user_identity_user_id ON user_identity(user_id);`. Include a 2-line header comment describing the table purpose (pattern-match `04__memo_share.sql`).
|
||||
3. `store/migration/mysql/0.28/00__user_identity.sql`: same logical schema with MySQL syntax — backticked identifiers, `INT NOT NULL AUTO_INCREMENT PRIMARY KEY`, `VARCHAR(256)` for `provider`, `VARCHAR(256)` for `extern_uid` (so unique key fits within index limits), `BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP())` for timestamps, `UNIQUE(provider, extern_uid)`, plus `CREATE INDEX idx_user_identity_user_id ON user_identity(user_id);`.
|
||||
4. Append a `-- user_identity` section to each `LATEST.sql` mirroring the corresponding migration file (schema only, same indentation style used by neighboring tables in that file).
|
||||
|
||||
**Boundaries**: Must NOT alter the `user` or `idp` tables; must NOT add FK from `user_identity.provider` to `idp.uid`; must NOT add columns beyond `id`, `user_id`, `provider`, `extern_uid`, `created_ts`, `updated_ts`.
|
||||
|
||||
**Dependencies**: None.
|
||||
|
||||
**Expected Outcome**: New migration files exist; `LATEST.sql` for each backend contains a `user_identity` table block and its `user_id` index.
|
||||
|
||||
**Validation**:
|
||||
- `rg -n "CREATE TABLE user_identity" store/migration` — expects one hit per backend in both the 0.28 migration and `LATEST.sql` (6 hits total).
|
||||
- `rg -n "UNIQUE ?\\(provider, extern_uid\\)" store/migration` — expects 6 hits total.
|
||||
- `go build ./...` — expects PASS (no code changes affect the build; confirms no stray syntax issues).
|
||||
|
||||
---
|
||||
|
||||
### T2: Add `store.UserIdentity` model, `Store` methods, and driver interface [M]
|
||||
|
||||
**Objective**: Provide a Go-level abstraction for the `user_identity` record with create/read operations wired through `store.Driver` (design §2, G3, G5).
|
||||
|
||||
**Size**: M (one new store file, one interface edit; simple CRUD-shaped code).
|
||||
|
||||
**Files**:
|
||||
- Create: `store/user_identity.go`
|
||||
- Modify: `store/driver.go`
|
||||
|
||||
**Implementation**:
|
||||
1. `store/user_identity.go`:
|
||||
- Types:
|
||||
```go
|
||||
type UserIdentity struct {
|
||||
ID int32
|
||||
UserID int32
|
||||
Provider string
|
||||
ExternUID string
|
||||
CreatedTs int64
|
||||
UpdatedTs int64
|
||||
}
|
||||
|
||||
type FindUserIdentity struct {
|
||||
ID *int32
|
||||
UserID *int32
|
||||
Provider *string
|
||||
ExternUID *string
|
||||
}
|
||||
```
|
||||
- Store methods (thin passthroughs to driver):
|
||||
```go
|
||||
func (s *Store) CreateUserIdentity(ctx context.Context, create *UserIdentity) (*UserIdentity, error)
|
||||
func (s *Store) ListUserIdentities(ctx context.Context, find *FindUserIdentity) ([]*UserIdentity, error)
|
||||
func (s *Store) GetUserIdentity(ctx context.Context, find *FindUserIdentity) (*UserIdentity, error) // returns (nil, nil) on no match
|
||||
```
|
||||
- No update/delete methods in this issue (design §2: create/read only).
|
||||
2. `store/driver.go`: extend the `Driver` interface with:
|
||||
```go
|
||||
// UserIdentity model related methods.
|
||||
CreateUserIdentity(ctx context.Context, create *UserIdentity) (*UserIdentity, error)
|
||||
ListUserIdentities(ctx context.Context, find *FindUserIdentity) ([]*UserIdentity, error)
|
||||
```
|
||||
`GetUserIdentity` in `store` can be implemented locally by calling `ListUserIdentities` with `Limit`-free semantics and returning the first row, matching the `GetMemoShare`/`GetIdentityProvider` pattern (no new driver method required for "get").
|
||||
|
||||
**Boundaries**: Must NOT add fields to `store.User` or `store.UpdateUser`; must NOT add update/delete methods.
|
||||
|
||||
**Dependencies**: None (T3–T5 will satisfy the new interface methods).
|
||||
|
||||
**Expected Outcome**: `store.UserIdentity`, `FindUserIdentity`, and three `Store` methods exist; `Driver` interface declares the two new methods.
|
||||
|
||||
**Validation**:
|
||||
- `go build ./store/...` — expects FAIL until T3–T5 implement the interface on each driver. Record as expected; final pass comes at end of T5.
|
||||
- `rg -n "CreateUserIdentity|ListUserIdentities" store/driver.go store/user_identity.go` — expects method declarations in both files.
|
||||
|
||||
---
|
||||
|
||||
### T3: Implement SQLite driver for `user_identity` [M]
|
||||
|
||||
**Objective**: Implement `CreateUserIdentity` and `ListUserIdentities` for SQLite so the interface declared in T2 is satisfied (design §2).
|
||||
|
||||
**Size**: M (one new driver file; mirrors existing `memo_share.go` patterns).
|
||||
|
||||
**Files**:
|
||||
- Create: `store/db/sqlite/user_identity.go`
|
||||
|
||||
**Implementation**:
|
||||
1. `CreateUserIdentity`:
|
||||
- Insert columns `user_id`, `provider`, `extern_uid` using `?` placeholders.
|
||||
- Use `RETURNING id, created_ts, updated_ts` to populate generated fields, same pattern as `store/db/sqlite/memo_share.go:24`.
|
||||
- Return the passed-in `create` struct with generated fields populated, or the error from `QueryRowContext(...).Scan(...)` (unique-constraint violation surfaces to caller unchanged).
|
||||
2. `ListUserIdentities`:
|
||||
- `where := []string{"1 = 1"}`; append clauses for `find.ID`, `find.UserID`, `find.Provider`, `find.ExternUID` when non-nil.
|
||||
- `SELECT id, user_id, provider, extern_uid, created_ts, updated_ts FROM user_identity WHERE ... ORDER BY id ASC`.
|
||||
- Scan rows into `[]*store.UserIdentity`; return `[]*store.UserIdentity{}` on no rows (not nil).
|
||||
|
||||
**Boundaries**: Must NOT introduce transaction helpers, upsert semantics, or extra scan columns.
|
||||
|
||||
**Dependencies**: T2.
|
||||
|
||||
**Expected Outcome**: SQLite driver compiles and returns populated rows.
|
||||
|
||||
**Validation**:
|
||||
- `go build ./store/db/sqlite/...` — expects PASS.
|
||||
|
||||
---
|
||||
|
||||
### T4: Implement Postgres driver for `user_identity` [M]
|
||||
|
||||
**Objective**: Mirror T3 for Postgres using `$N` placeholders and `SERIAL` semantics (design §2).
|
||||
|
||||
**Size**: M (one new driver file; mirrors `store/db/postgres/memo_share.go`).
|
||||
|
||||
**Files**:
|
||||
- Create: `store/db/postgres/user_identity.go`
|
||||
|
||||
**Implementation**:
|
||||
- Same shape as T3, but:
|
||||
- Use `placeholder(n)` / `placeholders(n)` helpers from `store/db/postgres/common.go`.
|
||||
- Insert stmt `INSERT INTO user_identity (user_id, provider, extern_uid) VALUES (...) RETURNING id, created_ts, updated_ts`.
|
||||
- List query identical SQL shape to SQLite (no backticks in Postgres; match `memo_share.go` style).
|
||||
|
||||
**Boundaries**: Same as T3.
|
||||
|
||||
**Dependencies**: T2.
|
||||
|
||||
**Expected Outcome**: Postgres driver compiles.
|
||||
|
||||
**Validation**:
|
||||
- `go build ./store/db/postgres/...` — expects PASS.
|
||||
|
||||
---
|
||||
|
||||
### T5: Implement MySQL driver for `user_identity` [M]
|
||||
|
||||
**Objective**: Mirror T3/T4 for MySQL, using `LastInsertId()` + re-read pattern (MySQL's driver does not support `RETURNING`; design §2).
|
||||
|
||||
**Size**: M (one new driver file; mirrors `store/db/mysql/memo_share.go`).
|
||||
|
||||
**Files**:
|
||||
- Create: `store/db/mysql/user_identity.go`
|
||||
|
||||
**Implementation**:
|
||||
- `CreateUserIdentity`:
|
||||
- `INSERT INTO user_identity (user_id, provider, extern_uid) VALUES (?, ?, ?)` via `ExecContext`.
|
||||
- Get `LastInsertId()`, re-fetch via `GetUserIdentity(... ID: &id)` helper (internal unexported `listUserIdentitiesByID` or reuse `ListUserIdentities` with `FindUserIdentity{ID: &id}` + take first result).
|
||||
- Mirror `memo_share.go` error-handling style (return `errors.Errorf("failed to create user identity")` when re-fetch returns nil, like memo_share does).
|
||||
- `ListUserIdentities`:
|
||||
- Same shape as T3, using backticked column names (`` `user_id` ``, `` `provider` ``, `` `extern_uid` ``) and `?` placeholders, matching the MySQL idiom used in `memo_share.go`.
|
||||
|
||||
**Boundaries**: Same as T3.
|
||||
|
||||
**Dependencies**: T2.
|
||||
|
||||
**Expected Outcome**: MySQL driver compiles; full repo builds.
|
||||
|
||||
**Validation**:
|
||||
- `go build ./...` — expects PASS (entire repo compiles with all drivers satisfying the `Driver` interface introduced in T2).
|
||||
|
||||
---
|
||||
|
||||
### T6: Add store-layer tests for `user_identity` [M]
|
||||
|
||||
**Objective**: Exercise create + read paths plus the `(provider, extern_uid)` uniqueness guard across the active driver (G2).
|
||||
|
||||
**Size**: M (one new test file; patterns match existing store tests).
|
||||
|
||||
**Files**:
|
||||
- Create: `store/test/user_identity_test.go`
|
||||
|
||||
**Implementation**:
|
||||
1. `TestUserIdentityCreateAndGet`:
|
||||
- Create host user via `createTestingHostUser`.
|
||||
- `CreateUserIdentity` with `UserID=user.ID`, `Provider="idp-uid-1"`, `ExternUID="jane@example.com"`.
|
||||
- `GetUserIdentity` by `(Provider, ExternUID)` — assert match on `UserID`, `Provider`, `ExternUID`, non-zero `ID`, non-zero `CreatedTs`.
|
||||
2. `TestUserIdentityListByUserID`:
|
||||
- Create two identities under the same `UserID` with two different `Provider` values.
|
||||
- `ListUserIdentities` by `UserID` — assert length 2.
|
||||
3. `TestUserIdentityUniqueConflict`:
|
||||
- Insert one row with `(Provider="idp-A", ExternUID="sub-1")`.
|
||||
- Insert a second row with identical `(Provider, ExternUID)` for a different `UserID`.
|
||||
- Assert the second `CreateUserIdentity` returns a non-nil error (detection via `err != nil`; do not assert message since error strings differ per backend).
|
||||
4. `TestUserIdentitySameExternUIDDifferentProviders`:
|
||||
- Insert `(Provider="idp-A", ExternUID="sub-1")` and `(Provider="idp-B", ExternUID="sub-1")` under the same or different users.
|
||||
- Assert both inserts succeed (G2: uniqueness is scoped to the pair, not `extern_uid` alone).
|
||||
|
||||
**Boundaries**: Must NOT test SSO sign-in or auth service behavior; must NOT test migration contents beyond what `NewTestingStore` already executes.
|
||||
|
||||
**Dependencies**: T1–T5.
|
||||
|
||||
**Expected Outcome**: All four tests pass against SQLite.
|
||||
|
||||
**Validation**:
|
||||
- `go test ./store/test/ -run TestUserIdentity -count=1` — expects all 4 tests PASS.
|
||||
|
||||
---
|
||||
|
||||
### T7: Add SSO username derivation helper [M]
|
||||
|
||||
**Objective**: Produce a valid `User.Username` for new SSO-created users from profile fields, independent of `extern_uid` (design §4).
|
||||
|
||||
**Size**: M (one new file with helper + small unit test; self-contained logic).
|
||||
|
||||
**Files**:
|
||||
- Create: `server/router/api/v1/sso_username.go`
|
||||
|
||||
**Implementation**:
|
||||
1. `deriveSSOUsername(ctx context.Context, stores *store.Store, userInfo *idp.IdentityProviderUserInfo) (string, error)`:
|
||||
- Build ordered candidate list: `[userInfo.DisplayName, userInfo.Email, userInfo.Identifier]`, skipping empty values.
|
||||
- For each candidate:
|
||||
1. `base := normalizeToUsername(candidate)`
|
||||
2. If `validateUsername(base) == nil`:
|
||||
- If no existing user with `Username=base` (via `stores.GetUser(&FindUser{Username: &base})`), return `base`.
|
||||
- Else: try up to N=8 suffix retries `base + "-" + randomSuffix(6)`, where the trimmed base ensures total length ≤ 36. If a candidate passes `validateUsername` and is unique, return it.
|
||||
3. If all candidates are exhausted: fall back to a purely random username `"user-" + randomSuffix(10)` validated via `validateUsername`; retry up to 5 times before returning an error.
|
||||
4. `normalizeToUsername(s string) string`:
|
||||
- ASCII-fold / lowercase.
|
||||
- Replace every character not in `[a-zA-Z0-9]` with `-`.
|
||||
- Collapse consecutive `-` into one `-`.
|
||||
- Trim leading/trailing `-`.
|
||||
- Truncate to 36 chars, then re-trim trailing `-` so the string still ends in alphanumeric.
|
||||
- Return `""` if the result is empty or fully numeric (so the caller falls through to the next candidate).
|
||||
5. Use `internal/util.RandomString` for the random suffix (already imported by `auth_service.go`).
|
||||
|
||||
**Boundaries**: Must NOT modify `validateUsername` or `base.UIDMatcher`; must NOT write to `user_identity` or `user` directly; must NOT call `CreateUser`.
|
||||
|
||||
**Dependencies**: None.
|
||||
|
||||
**Expected Outcome**: New file `server/router/api/v1/sso_username.go` containing the exported-for-package helper `deriveSSOUsername` and internal `normalizeToUsername`.
|
||||
|
||||
**Validation**:
|
||||
- `go build ./server/router/api/v1/...` — expects PASS.
|
||||
- `go vet ./server/router/api/v1/...` — expects PASS.
|
||||
|
||||
---
|
||||
|
||||
### T8: Route SSO sign-in through `user_identity` linkage [L]
|
||||
|
||||
**Objective**: Replace the `FindUser{Username: &userInfo.Identifier}` lookup and `Username: userInfo.Identifier` user creation with `user_identity`-backed lookup and derived-username user creation, satisfying G1 and G2 end-to-end (design §3).
|
||||
|
||||
**Size**: L (non-trivial branching logic: lookup, miss path, registration gate, race recovery).
|
||||
|
||||
**Files**:
|
||||
- Modify: `server/router/api/v1/auth_service.go`
|
||||
|
||||
**Implementation** (in `SignIn`, SSO branch, replacing current lines ~124–173):
|
||||
|
||||
1. After `identifier_filter` check succeeds (existing `lines 124-133` unchanged), resolve the linkage:
|
||||
```go
|
||||
provider := identityProvider.Uid
|
||||
externUID := userInfo.Identifier
|
||||
existingIdentity, err := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
|
||||
Provider: &provider,
|
||||
ExternUID: &externUID,
|
||||
})
|
||||
// error handling → codes.Internal
|
||||
```
|
||||
2. **Hit path**: if `existingIdentity != nil`, load `s.Store.GetUser(ctx, &store.FindUser{ID: &existingIdentity.UserID})`; set `existingUser`; skip creation.
|
||||
3. **Miss path**: gate on `instanceGeneralSetting.DisallowUserRegistration` (reuse existing flow at current lines 143–149), then:
|
||||
1. `username, err := deriveSSOUsername(ctx, s.Store, userInfo)` — from T7. `codes.Internal` on error.
|
||||
2. Generate random password + bcrypt hash (unchanged from current lines 160–168).
|
||||
3. `user, err := s.Store.CreateUser(ctx, &store.User{Username: username, Role: store.RoleUser, Nickname: userInfo.DisplayName, Email: userInfo.Email, AvatarURL: userInfo.AvatarURL, PasswordHash: string(passwordHash)})`.
|
||||
4. `_, err := s.Store.CreateUserIdentity(ctx, &store.UserIdentity{UserID: user.ID, Provider: provider, ExternUID: externUID})`.
|
||||
5. **Race recovery**: if `CreateUserIdentity` returns an error whose message matches one of the known unique-constraint markers (`strings.Contains(err.Error(), "UNIQUE constraint failed")`, `"duplicate key"`, `"Duplicate entry")` — reusing the same pattern as `server/router/api/v1/memo_service.go:103–105`):
|
||||
- `_ = s.Store.DeleteUser(ctx, &store.DeleteUser{ID: user.ID})` (best-effort cleanup of the provisional local user).
|
||||
- Re-read the winning `user_identity` via `s.Store.GetUserIdentity(ctx, &FindUserIdentity{Provider: &provider, ExternUID: &externUID})`; if still nil, return `codes.Internal` (should not happen under correct semantics).
|
||||
- Load its user via `s.Store.GetUser(ctx, &FindUser{ID: &winner.UserID})`; set `existingUser`.
|
||||
6. On any other `CreateUserIdentity` error: best-effort `DeleteUser` cleanup, then return `codes.Internal`.
|
||||
7. On full success: set `existingUser = user`.
|
||||
|
||||
4. Leave the remainder of `SignIn` (row-status check, `doSignIn`, response construction) untouched.
|
||||
|
||||
**Boundaries**: Must NOT touch the password-credentials branch; must NOT modify `identifier_filter` logic; must NOT touch `doSignIn`, `SignOut`, or `RefreshToken`; must NOT add new fields to `SignInRequest`/`SignInResponse`.
|
||||
|
||||
**Dependencies**: T2, T3, T6 minimum for SQLite confidence; T7 for the derivation helper.
|
||||
|
||||
**Expected Outcome**:
|
||||
- Sign-in with an IdP-issued identifier that fails `base.UIDMatcher` (e.g., `jane@example.com`) succeeds: a `user_identity` row is created, and the local `User.Username` is a derived valid username.
|
||||
- Repeat sign-in for the same `(provider, extern_uid)` pair loads the same user by linkage, not by username.
|
||||
- Two IdPs emitting the same `extern_uid` can each link to their own local users without colliding (G2).
|
||||
|
||||
**Validation**:
|
||||
- `go build ./...` — expects PASS.
|
||||
- `go vet ./...` — expects PASS.
|
||||
- `go test ./store/test/ -run TestUserIdentity -count=1` — expects PASS (T6 regression check; ensures no store-layer drift).
|
||||
|
||||
## Out-of-Scope Tasks
|
||||
|
||||
The following are explicitly deferred per `definition.md` / `design.md` and will NOT be attempted during this execution:
|
||||
|
||||
- UI or API surfaces for linking/unlinking external identities.
|
||||
- Update or delete paths for `user_identity` rows.
|
||||
- Backfill / migration of existing users whose current `Username` matches an IdP identifier.
|
||||
- Non-OAUTH2 IdP types.
|
||||
- Protobuf or API changes to `SignInRequest`/`SignInResponse`.
|
||||
- Adding foreign keys between `user_identity.provider` and `idp.uid`.
|
||||
- Running PostgreSQL or MySQL integration tests locally (validation commands only cover SQLite, which is the default `DRIVER` in `store/test/store.go`).
|
||||
Reference in New Issue
Block a user