Files
WeKnora/client/auth.go
wizardchen f3d1210577 refactor(rbac): address PR 1 review feedback
Tighten correctness, security and observability of the tenant RBAC
foundation introduced in 3c4cdd70 in response to internal review.

Bugfixes
- custom_agents.runnable_by_viewer: drop the GORM `default:true` tag.
  GORM treats a plain bool's zero value as "unspecified" and silently
  rewrites it to the column default on insert, which would prevent
  admins from ever creating an agent with viewer-runtime disabled. The
  column-level DEFAULT TRUE on the database side still backstops rows
  inserted by raw SQL / migrations.
- TenantMemberRepository.HasAnyMembers: replace `Limit(1).Count(&n)`
  with `Take(&probe)`. GORM's Count ignores Limit and scans the whole
  table, which matters because this runs on the auth middleware's
  hot path for users without a cached membership.
- service/user.LoginWithOIDC: include User, Tenant and Memberships in
  the OIDCCallbackResponse so the OIDC flow returns the same shape as
  password login (previously User and friends were left nil).

Security
- middleware/auth.resolveTenantRole: restrict orphan-tenant
  auto-promotion to the caller's home tenant. Cross-tenant superuser
  visits and JWTs minted for foreign tenants no longer trigger silent
  Owner grants. Two regression tests pin the new behaviour.
- service/user.synthFallbackMembership: default the synthesised role
  to Viewer instead of Owner. The login response only feeds UI; the
  backend re-derives the real role from tenant_members on every
  request, so showing a Viewer UI is preferable to a misleading Owner
  UI that would surface admin controls the backend will then 403.
- migrations/versioned/000043_tenant_rbac.down.sql: gate the destructive
  drop behind a `weknora.allow_destructive_migration` GUC so an
  accidental `migrate down` cannot wipe role data in production.
- frontend/src/stores/auth.ts: comment currentTenantRole as
  rendering-only so future PRs cannot mistake the localStorage-backed
  value for an authorisation signal.

API contract
- Memberships in LoginResponse and OIDCCallbackResponse now serialises
  without omitempty, and buildMembershipsForUser / synthFallbackMembership
  always return a non-nil slice — frontend can rely on JSON `[]`.
- handler/auth.GetCurrentUser includes memberships so /auth/me refreshes
  recover currentTenantRole after a page reload.

Performance
- Add TenantService.GetTenantsByIDs (with the matching repo method) and
  use it inside buildMembershipsForUser to batch tenant-name lookups
  instead of issuing one query per membership.

Observability
- Replace the remaining `log.Printf` calls in middleware/auth with
  contextual logger.Warnf / logger.Infof so role resolution events ride
  the standard request-id / Langfuse trace plumbing. The successful
  auto-promote line is tagged `[audit]` for log mining.

Tests
- internal/application/service/tenant_member_test.go: hand-rolled
  fakeTenantMemberRepo plus 11 cases covering AddMember validation,
  EnsureOwner idempotence, "cannot demote/remove the last Owner",
  same-role no-op, ErrInvalidTenantRole / ErrMembershipNotFound, and
  the TenantRole.HasPermission lattice.
- internal/middleware/auth_role_test.go: fakeMemberService plus 8 cases
  pinning every branch of resolveTenantRole, including the two H1
  regressions (cross-tenant superuser must not auto-promote; orphan
  auto-promote requires the home tenant).
2026-05-18 17:28:58 +08:00

166 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package client
import (
"context"
"fmt"
"net/http"
"time"
)
// LoginRequest is the body for POST /api/v1/auth/login.
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// LoginResponse is the body returned by POST /api/v1/auth/login.
//
// Token is the JWT access token; RefreshToken renews it via Auth.Refresh.
// ActiveTenant is the tenant whose ID is encoded in the JWT — every
// subsequent request is scoped to it until /auth/switch-tenant is called.
// Memberships lists every tenant the user can access along with their
// role in each, so callers can build a tenant switcher UI without a
// follow-up request.
type LoginResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
User *AuthUser `json:"user,omitempty"`
// ActiveTenant is the tenant whose ID is encoded in the JWT. Use
// this field; Tenant is preserved as a compatibility alias only.
ActiveTenant *AuthTenant `json:"active_tenant,omitempty"`
// Tenant is a deprecated alias kept so SDK callers compiled against
// the pre-RBAC release continue to find the field by name. The
// server emits active_tenant; this field is populated on the client
// side by GetTenant() and will be removed in a future major release.
//
// Deprecated: use ActiveTenant.
Tenant *AuthTenant `json:"-"`
Memberships []AuthMembership `json:"memberships,omitempty"`
Token string `json:"token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
}
// GetTenant returns the active tenant, preferring ActiveTenant and
// falling back to the deprecated Tenant alias. SDK code should prefer
// reading ActiveTenant directly; this helper exists to keep callers that
// only know about the old field name compiling and working.
func (r *LoginResponse) GetTenant() *AuthTenant {
if r == nil {
return nil
}
if r.ActiveTenant != nil {
return r.ActiveTenant
}
return r.Tenant
}
// AuthMembership pairs a tenant ID with the user's role in that tenant.
// Mirrors types.Membership on the server.
type AuthMembership struct {
TenantID uint64 `json:"tenant_id"`
TenantName string `json:"tenant_name,omitempty"`
Role string `json:"role"`
}
// AuthUser is the principal returned by /auth/login and /auth/me.
//
// Fields mirror the server's UserInfo projection (no PasswordHash).
type AuthUser struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Avatar string `json:"avatar,omitempty"`
TenantID uint64 `json:"tenant_id"`
IsActive bool `json:"is_active"`
CanAccessAllTenants bool `json:"can_access_all_tenants,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
// AuthTenant is the tenant projection returned alongside AuthUser.
type AuthTenant struct {
ID uint64 `json:"id"`
Name string `json:"name"`
}
// CurrentUserResponse is the body of GET /api/v1/auth/me.
type CurrentUserResponse struct {
Success bool `json:"success"`
Data struct {
User *AuthUser `json:"user,omitempty"`
Tenant *AuthTenant `json:"tenant,omitempty"`
} `json:"data"`
}
// Canonical paths for the auth endpoints. Exposed so callers building
// HTTP middleware (e.g. the CLI's 401-retry transport) can classify
// requests without re-hardcoding the literals.
const (
PathAuthLogin = "/api/v1/auth/login"
PathAuthRefresh = "/api/v1/auth/refresh"
)
// RefreshTokenResponse is the body of POST /api/v1/auth/refresh.
type RefreshTokenResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
// Login authenticates with email + password and returns the JWT access token,
// refresh token, and principal info. Maps to POST /api/v1/auth/login.
//
// Used by `weknora auth login`.
func (c *Client) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/auth/login", req, nil)
if err != nil {
return nil, fmt.Errorf("login request: %w", err)
}
var out LoginResponse
if err := parseResponse(resp, &out); err != nil {
return nil, err
}
// 后端已将 tenant 字段重命名为 active_tenant为照顾仍读取旧字段名的下游
// 调用者,在反序列化后镜像一份到 Tenant 上。两者总是指向同一指针。
out.Tenant = out.ActiveTenant
return &out, nil
}
// GetCurrentUser returns the currently authenticated principal and the
// tenant projection. Maps to GET /api/v1/auth/me; the bearer token must
// already be set on the client (use WithBearerToken).
//
// Used by `weknora auth status` and `weknora whoami`.
func (c *Client) GetCurrentUser(ctx context.Context) (*CurrentUserResponse, error) {
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/auth/me", nil, nil)
if err != nil {
return nil, fmt.Errorf("get current user: %w", err)
}
var out CurrentUserResponse
if err := parseResponse(resp, &out); err != nil {
return nil, err
}
return &out, nil
}
// RefreshToken renews the JWT access token using a refresh token.
// Maps to POST /api/v1/auth/refresh.
//
// Callers (`weknora auth refresh`) read the refresh token from secrets,
// invoke this method, and persist both new tokens. The SDK does not touch
// the secrets store directly — it stays a transport-only layer.
func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*RefreshTokenResponse, error) {
body := struct {
RefreshToken string `json:"refreshToken"`
}{RefreshToken: refreshToken}
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/auth/refresh", body, nil)
if err != nil {
return nil, fmt.Errorf("refresh token: %w", err)
}
var out RefreshTokenResponse
if err := parseResponse(resp, &out); err != nil {
return nil, err
}
return &out, nil
}