WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ErrLastOwner is returned by the atomic demote / remove repo helpers
|
||||
// when the operation would leave the tenant without an active Owner.
|
||||
// The service layer maps this to its own ErrLastOwner sentinel (same
|
||||
// semantic; just kept separate so the repo doesn't import service).
|
||||
var ErrLastOwner = errors.New("repository: last active owner")
|
||||
|
||||
// forUpdateClause returns the gorm SELECT ... FOR UPDATE clause. Kept
|
||||
// in one place so we can swap it out for `clause.Locking{Strength: "UPDATE"}`
|
||||
// on databases that don't support row-level locking (none in our matrix,
|
||||
// but keeps the seam if SQLite-lite ever needs a no-op).
|
||||
func forUpdateClause() clause.Expression {
|
||||
return clause.Locking{Strength: "UPDATE"}
|
||||
}
|
||||
|
||||
// tenantMemberRepository implements interfaces.TenantMemberRepository.
|
||||
type tenantMemberRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewTenantMemberRepository creates a new tenant member repository.
|
||||
func NewTenantMemberRepository(db *gorm.DB) interfaces.TenantMemberRepository {
|
||||
return &tenantMemberRepository{db: db}
|
||||
}
|
||||
|
||||
// TenantUsersQuery joins non-deleted users (u) and memberships (m) for management
|
||||
// views. Suspended memberships and inactive users remain visible to administrators.
|
||||
func TenantUsersQuery(db *gorm.DB, tenantID uint64) *gorm.DB {
|
||||
return db.Table("tenant_members m").
|
||||
Joins("JOIN users u ON u.id=m.user_id AND u.deleted_at IS NULL").
|
||||
Where("m.tenant_id=? AND m.deleted_at IS NULL", tenantID)
|
||||
}
|
||||
|
||||
// Create inserts a new active membership row. Status defaults to
|
||||
// TenantMemberStatusActive when the caller leaves it blank, and JoinedAt
|
||||
// defaults to the current time, matching service-layer expectations.
|
||||
func (r *tenantMemberRepository) Create(ctx context.Context, member *types.TenantMember) error {
|
||||
if member.Status == "" {
|
||||
member.Status = types.TenantMemberStatusActive
|
||||
}
|
||||
if member.JoinedAt.IsZero() {
|
||||
member.JoinedAt = time.Now()
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(member).Error
|
||||
}
|
||||
|
||||
// Get returns the active membership for (userID, tenantID), or (nil, nil)
|
||||
// if no such row exists. Errors are propagated unchanged for any other case.
|
||||
func (r *tenantMemberRepository) Get(ctx context.Context, userID string, tenantID uint64) (*types.TenantMember, error) {
|
||||
var member types.TenantMember
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND tenant_id = ?", userID, tenantID).
|
||||
First(&member).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &member, nil
|
||||
}
|
||||
|
||||
// ListByUser returns every active membership owned by the user, ordered
|
||||
// by joined_at ascending so the home tenant (created at registration)
|
||||
// naturally appears first.
|
||||
func (r *tenantMemberRepository) ListByUser(ctx context.Context, userID string) ([]*types.TenantMember, error) {
|
||||
var members []*types.TenantMember
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
Order("joined_at ASC, id ASC").
|
||||
Find(&members).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
// ListByTenant returns every active membership inside the tenant.
|
||||
func (r *tenantMemberRepository) ListByTenant(ctx context.Context, tenantID uint64) ([]*types.TenantMember, error) {
|
||||
var members []*types.TenantMember
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("tenant_id = ?", tenantID).
|
||||
Order("joined_at ASC, id ASC").
|
||||
Find(&members).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
// CountFilteredByTenant counts active tenant membership rows, optionally
|
||||
// restricted to users whose email or username matches search.
|
||||
func (r *tenantMemberRepository) CountFilteredByTenant(
|
||||
ctx context.Context, tenantID uint64, search string,
|
||||
) (int64, error) {
|
||||
search = strings.TrimSpace(search)
|
||||
q := r.db.WithContext(ctx).Model(&types.TenantMember{}).
|
||||
Where("tenant_members.tenant_id = ?", tenantID)
|
||||
var total int64
|
||||
var err error
|
||||
if search == "" {
|
||||
err = q.Count(&total).Error
|
||||
} else {
|
||||
like := "%" + escapeLikePattern(search) + "%"
|
||||
err = q.
|
||||
Joins(`INNER JOIN users ON users.id = tenant_members.user_id AND users.deleted_at IS NULL`).
|
||||
Where(`(LOWER(users.email) LIKE LOWER(?) OR LOWER(users.username) LIKE LOWER(?))`, like, like).
|
||||
Count(&total).Error
|
||||
}
|
||||
return total, err
|
||||
}
|
||||
|
||||
// ListPagedByTenant lists active memberships with stable sort.
|
||||
func (r *tenantMemberRepository) ListPagedByTenant(
|
||||
ctx context.Context, tenantID uint64, search string, offset, limit int,
|
||||
) ([]*types.TenantMember, error) {
|
||||
search = strings.TrimSpace(search)
|
||||
var members []*types.TenantMember
|
||||
q := r.db.WithContext(ctx).Model(&types.TenantMember{}).
|
||||
Where("tenant_members.tenant_id = ?", tenantID).
|
||||
Order("tenant_members.joined_at ASC, tenant_members.id ASC").
|
||||
Offset(offset).
|
||||
Limit(limit)
|
||||
|
||||
var err error
|
||||
if search == "" {
|
||||
err = q.Find(&members).Error
|
||||
} else {
|
||||
like := "%" + escapeLikePattern(search) + "%"
|
||||
err = q.
|
||||
Joins(`INNER JOIN users ON users.id = tenant_members.user_id AND users.deleted_at IS NULL`).
|
||||
Where(`(LOWER(users.email) LIKE LOWER(?) OR LOWER(users.username) LIKE LOWER(?))`, like, like).
|
||||
Find(&members).Error
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
// UpdateRole changes the role of an existing active membership.
|
||||
func (r *tenantMemberRepository) UpdateRole(ctx context.Context, userID string, tenantID uint64, role types.TenantRole) error {
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&types.TenantMember{}).
|
||||
Where("user_id = ? AND tenant_id = ?", userID, tenantID).
|
||||
Updates(map[string]any{
|
||||
"role": role,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SoftDelete marks the membership row as deleted. GORM's soft-delete
|
||||
// support populates DeletedAt automatically.
|
||||
func (r *tenantMemberRepository) SoftDelete(ctx context.Context, userID string, tenantID uint64) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND tenant_id = ?", userID, tenantID).
|
||||
Delete(&types.TenantMember{}).Error
|
||||
}
|
||||
|
||||
// CountActiveOwners reports the number of active owner rows in the tenant.
|
||||
func (r *tenantMemberRepository) CountActiveOwners(ctx context.Context, tenantID uint64) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&types.TenantMember{}).
|
||||
Where("tenant_id = ? AND role = ? AND status = ?",
|
||||
tenantID, types.TenantRoleOwner, types.TenantMemberStatusActive).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// DemoteOwnerAtomically transitions an Owner row to a non-Owner role
|
||||
// while holding an UPDATE lock on the tenant's other Owner rows. This
|
||||
// closes the TOCTOU window in the old "Get → CountActiveOwners → Update"
|
||||
// sequence where two concurrent demotions of two different Owners could
|
||||
// each read count=2, then both commit, leaving the tenant ownerless.
|
||||
//
|
||||
// Returns:
|
||||
// - ErrLastOwner when there is no other active Owner.
|
||||
// - gorm.ErrRecordNotFound when the row isn't there anymore (race
|
||||
// between concurrent removes); callers map this to ErrMembershipNotFound.
|
||||
// - any other error verbatim for the caller to log / surface.
|
||||
//
|
||||
// The caller is responsible for verifying the *current* role is Owner
|
||||
// before invoking this; the method is purposely narrow (it only handles
|
||||
// the dangerous demotion path) so other UpdateRole transitions can keep
|
||||
// using the cheap single-statement UpdateRole above.
|
||||
func (r *tenantMemberRepository) DemoteOwnerAtomically(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
tenantID uint64,
|
||||
newRole types.TenantRole,
|
||||
) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Lock every other active Owner row in the tenant. Locking ONLY
|
||||
// owners (not the row being demoted) is enough: a concurrent
|
||||
// demote of the locked row will block on this same SELECT.
|
||||
var locked []types.TenantMember
|
||||
err := tx.
|
||||
Clauses(forUpdateClause()).
|
||||
Where("tenant_id = ? AND user_id <> ? AND role = ? AND status = ?",
|
||||
tenantID, userID, types.TenantRoleOwner, types.TenantMemberStatusActive).
|
||||
Find(&locked).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(locked) == 0 {
|
||||
return ErrLastOwner
|
||||
}
|
||||
res := tx.
|
||||
Model(&types.TenantMember{}).
|
||||
Where("user_id = ? AND tenant_id = ?", userID, tenantID).
|
||||
Updates(map[string]any{
|
||||
"role": newRole,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveOwnerAtomically soft-deletes an Owner row under the same lock
|
||||
// as DemoteOwnerAtomically. Same return semantics.
|
||||
func (r *tenantMemberRepository) RemoveOwnerAtomically(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
tenantID uint64,
|
||||
) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var locked []types.TenantMember
|
||||
err := tx.
|
||||
Clauses(forUpdateClause()).
|
||||
Where("tenant_id = ? AND user_id <> ? AND role = ? AND status = ?",
|
||||
tenantID, userID, types.TenantRoleOwner, types.TenantMemberStatusActive).
|
||||
Find(&locked).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(locked) == 0 {
|
||||
return ErrLastOwner
|
||||
}
|
||||
res := tx.
|
||||
Where("user_id = ? AND tenant_id = ?", userID, tenantID).
|
||||
Delete(&types.TenantMember{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// HasAnyMembers reports whether the tenant has at least one active
|
||||
// membership row. Uses a LIMIT 1 SELECT (instead of COUNT(*)) so the query
|
||||
// short-circuits after the first match — important because this is on the
|
||||
// auth middleware's hot path for users without a cached membership.
|
||||
func (r *tenantMemberRepository) HasAnyMembers(ctx context.Context, tenantID uint64) (bool, error) {
|
||||
var probe struct {
|
||||
ID uint64
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&types.TenantMember{}).
|
||||
Select("id").
|
||||
Where("tenant_id = ? AND status = ?", tenantID, types.TenantMemberStatusActive).
|
||||
Limit(1).
|
||||
Take(&probe).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
Reference in New Issue
Block a user