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,632 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apprepo "github.com/Tencent/WeKnora/internal/application/repository"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by tenantInvitationService. Callers compare
|
||||
// with errors.Is to render the appropriate HTTP responses.
|
||||
var (
|
||||
// ErrInvitationNotFound is returned when no invitation row matches
|
||||
// the supplied id.
|
||||
ErrInvitationNotFound = errors.New("invitation not found")
|
||||
|
||||
// ErrPendingInvitationExists is returned by Create when a pending
|
||||
// invitation for (tenant, invitee) is already in flight. The
|
||||
// handler maps this to 409.
|
||||
ErrPendingInvitationExists = errors.New("a pending invitation for this user already exists")
|
||||
|
||||
// ErrAlreadyMember is returned by Create when the invitee is
|
||||
// already an active member of the tenant; sending an invite would
|
||||
// be a no-op at best and a confusing UX at worst.
|
||||
ErrAlreadyMember = errors.New("user is already an active member of the tenant")
|
||||
|
||||
// ErrInvitationNotPending is returned by Accept / Decline / Revoke
|
||||
// when the row exists but has already been finalised. Maps to 409.
|
||||
ErrInvitationNotPending = errors.New("invitation is no longer pending")
|
||||
|
||||
// ErrInvitationExpired is returned by Accept / Decline when the
|
||||
// pending row has aged past its expires_at. Maps to 410.
|
||||
ErrInvitationExpired = errors.New("invitation has expired")
|
||||
|
||||
// ErrInvitationForbidden is returned by Accept / Decline when the
|
||||
// caller is not the invitee. Owner-driven Revoke is gated at the
|
||||
// route layer so this error only surfaces on the /me/ paths.
|
||||
ErrInvitationForbidden = errors.New("only the invitee can accept or decline this invitation")
|
||||
|
||||
// ErrInvitationTokenInvalid is returned by LookupByToken /
|
||||
// AcceptByToken when the supplied plaintext token does not match
|
||||
// any active share-link row. The handler maps this to 410 Gone.
|
||||
// We deliberately collapse "unknown" / "expired" / "revoked" into
|
||||
// a single sentinel so an attacker can't probe which slots used
|
||||
// to exist.
|
||||
ErrInvitationTokenInvalid = errors.New("invitation token is invalid or has been revoked")
|
||||
)
|
||||
|
||||
// defaultInvitationTTL is the lifetime of a pending invitation before
|
||||
// the lazy sweep transitions it to expired. Operator override is via
|
||||
// the WEKNORA_INVITATION_TTL env var (Go duration: "168h", "7d-ish");
|
||||
// keeping this out of TenantConfig avoids a yaml migration for a knob
|
||||
// that almost nobody is going to tweak.
|
||||
const defaultInvitationTTL = 7 * 24 * time.Hour
|
||||
|
||||
// invitationTTL resolves the effective TTL at call time so an operator
|
||||
// can hot-reload the override without restarting. The env var is parsed
|
||||
// once per call; cost is negligible and beats a goroutine watching the
|
||||
// environment.
|
||||
func invitationTTL() time.Duration {
|
||||
raw := os.Getenv("WEKNORA_INVITATION_TTL")
|
||||
if raw == "" {
|
||||
return defaultInvitationTTL
|
||||
}
|
||||
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
// Allow "604800" seconds for callers that don't like Go duration
|
||||
// syntax — same shape as the rest of our int env knobs.
|
||||
if secs, err := strconv.ParseInt(raw, 10, 64); err == nil && secs > 0 {
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
return defaultInvitationTTL
|
||||
}
|
||||
|
||||
// tenantInvitationService implements interfaces.TenantInvitationService.
|
||||
type tenantInvitationService struct {
|
||||
repo interfaces.TenantInvitationRepository
|
||||
memberSvc interfaces.TenantMemberService
|
||||
audit interfaces.AuditLogService // optional; nil ⇒ no audit, business ops still succeed
|
||||
now func() time.Time // injection seam for tests
|
||||
}
|
||||
|
||||
// NewTenantInvitationService wires the dependencies. memberSvc is
|
||||
// required (Accept must create the tenant_members row); audit is
|
||||
// optional and matches the same nil-safe pattern tenantMemberService
|
||||
// uses.
|
||||
func NewTenantInvitationService(
|
||||
repo interfaces.TenantInvitationRepository,
|
||||
memberSvc interfaces.TenantMemberService,
|
||||
audit interfaces.AuditLogService,
|
||||
) interfaces.TenantInvitationService {
|
||||
return &tenantInvitationService{
|
||||
repo: repo,
|
||||
memberSvc: memberSvc,
|
||||
audit: audit,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// emitAudit is the best-effort audit hook; mirrors tenantMemberService.
|
||||
func (s *tenantInvitationService) emitAudit(ctx context.Context, entry *types.AuditLog) {
|
||||
if s.audit == nil {
|
||||
return
|
||||
}
|
||||
_ = s.audit.Log(ctx, entry)
|
||||
}
|
||||
|
||||
// detailsFor packs the role + invitation id into the audit Details so a
|
||||
// reader can reconstruct "Alice invited Bob as Admin (inv #42)" without
|
||||
// joining back to the invitations table.
|
||||
func detailsFor(invID uint64, role types.TenantRole) types.JSON {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"invitation_id": invID,
|
||||
"role": string(role),
|
||||
})
|
||||
return types.JSON(b)
|
||||
}
|
||||
|
||||
// sweep transitions overdue pending rows to expired before any List/
|
||||
// Accept/Decline/Count read. Failures are logged and swallowed — a
|
||||
// transient sweep error must not block a user from reading their
|
||||
// inbox; the next call will sweep again.
|
||||
func (s *tenantInvitationService) sweep(ctx context.Context) {
|
||||
if _, err := s.repo.SweepExpired(ctx, s.now()); err != nil {
|
||||
logger.Warnf(ctx, "tenant_invitation lazy sweep failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create issues a new pending invitation. Returns the standard
|
||||
// conflict sentinels for the duplicate-pending and already-member
|
||||
// cases.
|
||||
func (s *tenantInvitationService) Create(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
inviteeUserID string,
|
||||
role types.TenantRole,
|
||||
invitedBy *string,
|
||||
message string,
|
||||
) (*types.TenantInvitation, error) {
|
||||
if !role.IsValid() {
|
||||
return nil, ErrInvalidTenantRole
|
||||
}
|
||||
if err := rejectAPIKeyOwnerAssignment(ctx, role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Reject early if the invitee is already an active member; the
|
||||
// handler renders this as "they're already in" rather than the
|
||||
// generic conflict.
|
||||
existing, err := s.memberSvc.GetMembership(ctx, inviteeUserID, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil && existing.Status == types.TenantMemberStatusActive {
|
||||
return nil, ErrAlreadyMember
|
||||
}
|
||||
|
||||
now := s.now()
|
||||
inv := &types.TenantInvitation{
|
||||
TenantID: tenantID,
|
||||
InviteeUserID: inviteeUserID,
|
||||
InvitedBy: invitedBy,
|
||||
Role: role,
|
||||
Status: types.TenantInvitationStatusPending,
|
||||
Message: message,
|
||||
ExpiresAt: now.Add(invitationTTL()),
|
||||
}
|
||||
if err := s.repo.Create(ctx, inv); err != nil {
|
||||
if errors.Is(err, apprepo.ErrPendingInvitationExists) {
|
||||
return nil, ErrPendingInvitationExists
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.emitAudit(ctx, &types.AuditLog{
|
||||
TenantID: tenantID,
|
||||
ActorUserID: auditActor(ctx),
|
||||
ActorRole: auditActorRole(ctx),
|
||||
Action: types.AuditActionInvitationSent,
|
||||
TargetType: "tenant_invitation",
|
||||
TargetID: strconv.FormatUint(inv.ID, 10),
|
||||
TargetUserID: inviteeUserID,
|
||||
Outcome: types.AuditOutcomeSuccess,
|
||||
Details: detailsFor(inv.ID, role),
|
||||
})
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// Accept transitions a pending invitation into accepted AND creates
|
||||
// the active tenant_members row in the same flow. We do NOT wrap both
|
||||
// writes in a single DB transaction because TenantMemberService.AddMember
|
||||
// owns its own write + audit emit and reaching across services here
|
||||
// would force the audit-log writes to commit/rollback in lockstep.
|
||||
// Instead, we order operations so the user-visible failure mode is
|
||||
// "you couldn't accept the invitation"; if the membership insert fails
|
||||
// AFTER the invitation transition committed (rare; collision on the
|
||||
// tenant_members unique index would be the only realistic case), the
|
||||
// row already in tenant_members wins and a subsequent Accept call sees
|
||||
// ErrInvitationNotPending which the handler renders as 409.
|
||||
func (s *tenantInvitationService) Accept(
|
||||
ctx context.Context,
|
||||
invID uint64,
|
||||
callerUserID string,
|
||||
) (*types.TenantMember, error) {
|
||||
s.sweep(ctx)
|
||||
|
||||
inv, err := s.repo.GetByID(ctx, invID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if inv == nil {
|
||||
return nil, ErrInvitationNotFound
|
||||
}
|
||||
if inv.InviteeUserID != callerUserID {
|
||||
return nil, ErrInvitationForbidden
|
||||
}
|
||||
if inv.Status != types.TenantInvitationStatusPending {
|
||||
return nil, ErrInvitationNotPending
|
||||
}
|
||||
if inv.IsExpired(s.now()) {
|
||||
// The sweep above should have flipped it already, but a row
|
||||
// can age past expires_at between the sweep and this read.
|
||||
// Treat it as expired regardless.
|
||||
return nil, ErrInvitationExpired
|
||||
}
|
||||
|
||||
now := s.now()
|
||||
if err := s.repo.MarkStatusIfPending(ctx, invID, types.TenantInvitationStatusAccepted, now); err != nil {
|
||||
// Another goroutine (concurrent click) won the race. Honour
|
||||
// the state machine.
|
||||
return nil, ErrInvitationNotPending
|
||||
}
|
||||
|
||||
// Create the actual tenant_members row. Cross-service hop: the
|
||||
// member service handles its own audit (rbac.member_added) and
|
||||
// also enforces the (user, tenant) uniqueness invariant via the
|
||||
// repo. If it fails here the invitation is already accepted —
|
||||
// see comment above for why we don't rollback the invitation.
|
||||
member, err := s.memberSvc.AddMember(ctx, inv.InviteeUserID, inv.TenantID, inv.Role, inv.InvitedBy)
|
||||
if err != nil {
|
||||
// Special-case "already a member": that's the idempotent
|
||||
// outcome we want. Return the existing membership instead of
|
||||
// bubbling the error up to the invitee.
|
||||
if errors.Is(err, ErrMembershipAlreadyExists) {
|
||||
existing, getErr := s.memberSvc.GetMembership(ctx, inv.InviteeUserID, inv.TenantID)
|
||||
if getErr == nil && existing != nil {
|
||||
s.emitInvitationAccepted(ctx, inv)
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
logger.Errorf(ctx,
|
||||
"invitation %d accepted but tenant_members insert failed: %v",
|
||||
invID, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.emitInvitationAccepted(ctx, inv)
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// MarkPendingAcceptedIfExists reconciles a stale per-user pending row when
|
||||
// tenant.auto_accept_invitation joins the invitee directly. member_added
|
||||
// audit from AddMember is the authoritative trail; we only flip status here.
|
||||
func (s *tenantInvitationService) MarkPendingAcceptedIfExists(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
inviteeUserID string,
|
||||
) error {
|
||||
s.sweep(ctx)
|
||||
inv, err := s.repo.GetPendingByPair(ctx, tenantID, inviteeUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if inv == nil {
|
||||
return nil
|
||||
}
|
||||
return s.repo.MarkStatusIfPending(ctx, inv.ID, types.TenantInvitationStatusAccepted, s.now())
|
||||
}
|
||||
|
||||
// reconcilePendingInvitation closes a per-user invitation after another
|
||||
// successful join path (for example a multi-use share link) has already
|
||||
// established the membership. This keeps the invitation inbox consistent
|
||||
// without turning a bookkeeping failure into a failed join after the member
|
||||
// row has already been committed.
|
||||
func (s *tenantInvitationService) reconcilePendingInvitation(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
inviteeUserID string,
|
||||
) {
|
||||
if err := s.MarkPendingAcceptedIfExists(ctx, tenantID, inviteeUserID); err != nil {
|
||||
logger.Warnf(ctx,
|
||||
"failed to reconcile pending invitation for tenant %d user %s: %v",
|
||||
tenantID, inviteeUserID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// emitInvitationAccepted writes the rbac.invitation_accepted audit row.
|
||||
// Actor is the invitee (acting on their own inbox); target is the same
|
||||
// user since the action is self-directed.
|
||||
func (s *tenantInvitationService) emitInvitationAccepted(ctx context.Context, inv *types.TenantInvitation) {
|
||||
s.emitAudit(ctx, &types.AuditLog{
|
||||
TenantID: inv.TenantID,
|
||||
ActorUserID: auditActor(ctx),
|
||||
ActorRole: auditActorRole(ctx),
|
||||
Action: types.AuditActionInvitationAccepted,
|
||||
TargetType: "tenant_invitation",
|
||||
TargetID: strconv.FormatUint(inv.ID, 10),
|
||||
TargetUserID: inv.InviteeUserID,
|
||||
Outcome: types.AuditOutcomeSuccess,
|
||||
Details: detailsFor(inv.ID, inv.Role),
|
||||
})
|
||||
}
|
||||
|
||||
// Decline transitions the pending row into declined. Only the invitee
|
||||
// themselves can call this.
|
||||
func (s *tenantInvitationService) Decline(
|
||||
ctx context.Context,
|
||||
invID uint64,
|
||||
callerUserID string,
|
||||
) error {
|
||||
s.sweep(ctx)
|
||||
|
||||
inv, err := s.repo.GetByID(ctx, invID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if inv == nil {
|
||||
return ErrInvitationNotFound
|
||||
}
|
||||
if inv.InviteeUserID != callerUserID {
|
||||
return ErrInvitationForbidden
|
||||
}
|
||||
if inv.Status != types.TenantInvitationStatusPending {
|
||||
return ErrInvitationNotPending
|
||||
}
|
||||
if inv.IsExpired(s.now()) {
|
||||
return ErrInvitationExpired
|
||||
}
|
||||
|
||||
if err := s.repo.MarkStatusIfPending(ctx, invID, types.TenantInvitationStatusDeclined, s.now()); err != nil {
|
||||
return ErrInvitationNotPending
|
||||
}
|
||||
|
||||
s.emitAudit(ctx, &types.AuditLog{
|
||||
TenantID: inv.TenantID,
|
||||
ActorUserID: auditActor(ctx),
|
||||
ActorRole: auditActorRole(ctx),
|
||||
Action: types.AuditActionInvitationDeclined,
|
||||
TargetType: "tenant_invitation",
|
||||
TargetID: strconv.FormatUint(inv.ID, 10),
|
||||
TargetUserID: inv.InviteeUserID,
|
||||
Outcome: types.AuditOutcomeSuccess,
|
||||
Details: detailsFor(inv.ID, inv.Role),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Revoke transitions the pending row into revoked. Route-layer Owner
|
||||
// gate guarantees the caller is allowed to act on this tenant; this
|
||||
// method does not re-check role.
|
||||
func (s *tenantInvitationService) Revoke(ctx context.Context, invID uint64) error {
|
||||
s.sweep(ctx)
|
||||
|
||||
inv, err := s.repo.GetByID(ctx, invID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if inv == nil {
|
||||
return ErrInvitationNotFound
|
||||
}
|
||||
if inv.Status != types.TenantInvitationStatusPending {
|
||||
return ErrInvitationNotPending
|
||||
}
|
||||
|
||||
if err := s.repo.MarkStatusIfPending(ctx, invID, types.TenantInvitationStatusRevoked, s.now()); err != nil {
|
||||
return ErrInvitationNotPending
|
||||
}
|
||||
|
||||
s.emitAudit(ctx, &types.AuditLog{
|
||||
TenantID: inv.TenantID,
|
||||
ActorUserID: auditActor(ctx),
|
||||
ActorRole: auditActorRole(ctx),
|
||||
Action: types.AuditActionInvitationRevoked,
|
||||
TargetType: "tenant_invitation",
|
||||
TargetID: strconv.FormatUint(inv.ID, 10),
|
||||
TargetUserID: inv.InviteeUserID,
|
||||
Outcome: types.AuditOutcomeSuccess,
|
||||
Details: detailsFor(inv.ID, inv.Role),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID returns the row or (nil, nil) without sweeping. The handler
|
||||
// uses this for narrow per-row checks where running a full sweep just
|
||||
// to read one row would be wasted work; List* paths still sweep.
|
||||
func (s *tenantInvitationService) GetByID(
|
||||
ctx context.Context,
|
||||
invID uint64,
|
||||
) (*types.TenantInvitation, error) {
|
||||
return s.repo.GetByID(ctx, invID)
|
||||
}
|
||||
|
||||
// ListByTenant sweeps then returns. The tenant-side management UI
|
||||
// expects expired rows to surface correctly; running the sweep here
|
||||
// guarantees the page reflects reality even if no other code path has
|
||||
// touched the table recently.
|
||||
func (s *tenantInvitationService) ListByTenant(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
includeTerminal bool,
|
||||
) ([]*types.TenantInvitation, error) {
|
||||
s.sweep(ctx)
|
||||
return s.repo.ListByTenant(ctx, tenantID, includeTerminal)
|
||||
}
|
||||
|
||||
// ListTenantInvitationsPage sweeps then returns a page plus total rows
|
||||
// matching the same filter as ListByTenant.
|
||||
func (s *tenantInvitationService) ListTenantInvitationsPage(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
includeTerminal bool,
|
||||
page, pageSize int,
|
||||
) ([]*types.TenantInvitation, int64, error) {
|
||||
const (
|
||||
defSize = 20
|
||||
maxSize = 100
|
||||
)
|
||||
s.sweep(ctx)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = defSize
|
||||
}
|
||||
if pageSize > maxSize {
|
||||
pageSize = maxSize
|
||||
}
|
||||
total, err := s.repo.CountByTenantList(ctx, tenantID, includeTerminal)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
rows, err := s.repo.ListByTenantPage(ctx, tenantID, includeTerminal, offset, pageSize)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
// ListByInvitee sweeps then returns. Same reasoning as ListByTenant.
|
||||
func (s *tenantInvitationService) ListByInvitee(
|
||||
ctx context.Context,
|
||||
inviteeUserID string,
|
||||
includeTerminal bool,
|
||||
) ([]*types.TenantInvitation, error) {
|
||||
s.sweep(ctx)
|
||||
return s.repo.ListByInvitee(ctx, inviteeUserID, includeTerminal)
|
||||
}
|
||||
|
||||
// CountPendingByInvitee sweeps then counts. The avatar-row badge polls
|
||||
// this endpoint so a stale sweep would manifest as a phantom "1" on
|
||||
// the bell icon for the full polling interval; sweeping inline is
|
||||
// worth the extra UPDATE.
|
||||
func (s *tenantInvitationService) CountPendingByInvitee(
|
||||
ctx context.Context,
|
||||
inviteeUserID string,
|
||||
) (int64, error) {
|
||||
s.sweep(ctx)
|
||||
return s.repo.CountPendingByInvitee(ctx, inviteeUserID)
|
||||
}
|
||||
|
||||
// invitationTokenBytes is the raw entropy length for share-link
|
||||
// tokens before base64url encoding. 32 bytes -> 256 bits, well above
|
||||
// the 128-bit floor for unguessable opaque tokens.
|
||||
const invitationTokenBytes = 32
|
||||
|
||||
// generateShareLinkToken returns a freshly-randomised plaintext token
|
||||
// (base64url, no padding) for a new share-link invitation.
|
||||
func generateShareLinkToken() (string, error) {
|
||||
buf := make([]byte, invitationTokenBytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// CreateShareLink issues a multi-use share-link invitation. The token
|
||||
// is generated server-side and persisted plaintext on the row so the
|
||||
// management UI can re-display it on demand. Per-user invitation
|
||||
// constraints (already-member, duplicate-pending) do NOT apply here:
|
||||
// share-link rows have no specific invitee, multiple can coexist on
|
||||
// the same tenant, and consumption is non-destructive (see
|
||||
// AcceptByToken).
|
||||
func (s *tenantInvitationService) CreateShareLink(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
role types.TenantRole,
|
||||
invitedBy *string,
|
||||
message string,
|
||||
) (*types.TenantInvitation, string, error) {
|
||||
if !role.IsValid() {
|
||||
return nil, "", ErrInvalidTenantRole
|
||||
}
|
||||
if err := rejectAPIKeyOwnerAssignment(ctx, role); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
token, err := generateShareLinkToken()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
now := s.now()
|
||||
inv := &types.TenantInvitation{
|
||||
TenantID: tenantID,
|
||||
InviteeUserID: "", // share-link rows have no specific invitee
|
||||
Token: token,
|
||||
InvitedBy: invitedBy,
|
||||
Role: role,
|
||||
Status: types.TenantInvitationStatusPending,
|
||||
Message: message,
|
||||
ExpiresAt: now.Add(invitationTTL()),
|
||||
}
|
||||
if err := s.repo.Create(ctx, inv); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
s.emitAudit(ctx, &types.AuditLog{
|
||||
TenantID: tenantID,
|
||||
ActorUserID: auditActor(ctx),
|
||||
ActorRole: auditActorRole(ctx),
|
||||
Action: types.AuditActionInvitationSent,
|
||||
TargetType: "tenant_invitation",
|
||||
TargetID: strconv.FormatUint(inv.ID, 10),
|
||||
// TargetUserID intentionally empty — share-link has no invitee yet.
|
||||
Outcome: types.AuditOutcomeSuccess,
|
||||
Details: detailsFor(inv.ID, role),
|
||||
})
|
||||
return inv, token, nil
|
||||
}
|
||||
|
||||
// LookupByToken resolves a plaintext share-link token to its row.
|
||||
// Sweeps overdue rows first so an expired link is reflected as
|
||||
// expired rather than letting the registration page accept it for a
|
||||
// few extra seconds. Multi-use semantics: a successful lookup does
|
||||
// not consume or modify the row.
|
||||
func (s *tenantInvitationService) LookupByToken(
|
||||
ctx context.Context,
|
||||
plainToken string,
|
||||
) (*types.TenantInvitation, error) {
|
||||
plainToken = strings.TrimSpace(plainToken)
|
||||
if plainToken == "" {
|
||||
return nil, ErrInvitationTokenInvalid
|
||||
}
|
||||
s.sweep(ctx)
|
||||
inv, err := s.repo.GetActiveByToken(ctx, plainToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if inv == nil {
|
||||
return nil, ErrInvitationTokenInvalid
|
||||
}
|
||||
if inv.IsExpired(s.now()) {
|
||||
return nil, ErrInvitationTokenInvalid
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// AcceptByToken adds newUserID to the share-link's tenant + role.
|
||||
// Unlike Accept, the invitation row itself is NOT mutated — share-link
|
||||
// rows stay pending across uses. Idempotent: an existing membership
|
||||
// is returned untouched (callers shouldn't see role downgrade just
|
||||
// because they clicked the same link twice from different devices).
|
||||
func (s *tenantInvitationService) AcceptByToken(
|
||||
ctx context.Context,
|
||||
plainToken string,
|
||||
newUserID string,
|
||||
) (*types.TenantMember, error) {
|
||||
if newUserID == "" {
|
||||
return nil, errors.New("newUserID is required")
|
||||
}
|
||||
inv, err := s.LookupByToken(ctx, plainToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
member, err := s.memberSvc.AddMember(ctx, newUserID, inv.TenantID, inv.Role, inv.InvitedBy)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrMembershipAlreadyExists) {
|
||||
existing, getErr := s.memberSvc.GetMembership(ctx, newUserID, inv.TenantID)
|
||||
if getErr == nil && existing != nil {
|
||||
s.reconcilePendingInvitation(ctx, inv.TenantID, newUserID)
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
logger.Errorf(ctx,
|
||||
"share-link %d accept failed for user %s: %v",
|
||||
inv.ID, newUserID, err)
|
||||
return nil, err
|
||||
}
|
||||
// Bump usage counter so the management UI can show "N 人已加入".
|
||||
// Best-effort: a failure here doesn't undo the membership the user
|
||||
// just earned — log and move on. The counter is for display only;
|
||||
// audit log + tenant_members rows are the authoritative trail.
|
||||
if incErr := s.repo.IncrementAcceptedCount(ctx, inv.ID); incErr != nil {
|
||||
logger.Warnf(ctx,
|
||||
"share-link %d accepted_count bump failed (membership still created): %v",
|
||||
inv.ID, incErr)
|
||||
}
|
||||
// A user may have received a direct invitation and then joined through
|
||||
// a share link first. Close that direct invitation now so its notification
|
||||
// cannot be accepted a second time and produce a misleading audit event.
|
||||
s.reconcilePendingInvitation(ctx, inv.TenantID, newUserID)
|
||||
s.emitAudit(ctx, &types.AuditLog{
|
||||
TenantID: inv.TenantID,
|
||||
ActorUserID: auditActor(ctx),
|
||||
ActorRole: auditActorRole(ctx),
|
||||
Action: types.AuditActionInvitationAccepted,
|
||||
TargetType: "tenant_invitation",
|
||||
TargetID: strconv.FormatUint(inv.ID, 10),
|
||||
TargetUserID: newUserID,
|
||||
Outcome: types.AuditOutcomeSuccess,
|
||||
Details: detailsFor(inv.ID, inv.Role),
|
||||
})
|
||||
return member, nil
|
||||
}
|
||||
Reference in New Issue
Block a user