Files
xinmi/internal/types/principal.go
T
yawan f7fc992f57
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
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

194 lines
6.0 KiB
Go

package types
import (
"context"
"fmt"
"strings"
)
const (
PrincipalWebUser = "web_user"
PrincipalAPITenant = "api_tenant"
PrincipalAPIPlatform = "api_platform"
PrincipalAPIExternalUser = "api_external_user"
PrincipalIMUser = "im_user"
PrincipalEmbedChannel = "embed_channel"
PrincipalEmbedSession = "embed_session"
PrincipalEmbedVisitor = "embed_visitor"
)
// EmbedVisitorHeader is sent by the embed widget to identify a browser visitor
// across chat sessions within the same channel.
const EmbedVisitorHeader = "X-Embed-Visitor"
// SessionOwnerAPITenantKeyPrefix prefixes sessions.user_id for rows created by a
// tenant API key. The full owner id is "api_tenant_key:<tenantID>:<keyID>", so a
// LIKE '<prefix>%' selects every API-key session in the tenant.
const SessionOwnerAPITenantKeyPrefix = "api_tenant_key:"
// SessionOwnerAPIExternalUserPrefix prefixes sessions.user_id for rows created
// by a tenant API key whose request resolved an external-user identity. The
// remainder is "<tenantID>:<externalUserID>".
const SessionOwnerAPIExternalUserPrefix = PrincipalAPIExternalUser + ":"
// IsAPISessionOwnerID reports whether a stored session owner was produced by
// a tenant API-key request, with or without an external-user identity.
func IsAPISessionOwnerID(ownerID string) bool {
ownerID = strings.TrimSpace(ownerID)
return strings.HasPrefix(ownerID, SessionOwnerAPITenantKeyPrefix) ||
strings.HasPrefix(ownerID, SessionOwnerAPIExternalUserPrefix)
}
// Principal represents the terminal caller for per-subject isolation features.
// It is intentionally separate from UserID: many principals, such as IM users
// or embed visitors, are not WeKnora accounts and must not imply RBAC rights.
type Principal struct {
Type string
ID string
}
func (p Principal) Normalize() Principal {
return Principal{
Type: strings.TrimSpace(p.Type),
ID: strings.TrimSpace(p.ID),
}
}
func (p Principal) Valid() bool {
p = p.Normalize()
return p.Type != "" && p.ID != ""
}
func (p Principal) StorageID() string {
p = p.Normalize()
if !p.Valid() {
return ""
}
return p.Type + ":" + p.ID
}
func WithPrincipal(ctx context.Context, principal Principal) context.Context {
principal = principal.Normalize()
if !principal.Valid() {
return ctx
}
return context.WithValue(ctx, PrincipalContextKey, principal)
}
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
if ctx == nil {
return Principal{}, false
}
if p, ok := ctx.Value(PrincipalContextKey).(Principal); ok && p.Valid() {
return p.Normalize(), true
}
if uid, ok := UserIDFromContext(ctx); ok && strings.TrimSpace(uid) != "" {
return Principal{Type: PrincipalWebUser, ID: uid}, true
}
return Principal{}, false
}
func WithEmbedVisitorID(ctx context.Context, visitorID string) context.Context {
visitorID = strings.TrimSpace(visitorID)
if visitorID == "" {
return ctx
}
return context.WithValue(ctx, EmbedVisitorContextKey, visitorID)
}
func EmbedVisitorIDFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
if v, ok := ctx.Value(EmbedVisitorContextKey).(string); ok {
return strings.TrimSpace(v)
}
return ""
}
// EmbedSessionPrincipal identifies a single embed visitor chat session.
func EmbedSessionPrincipal(tenantID uint64, channelID, sessionID string) Principal {
return Principal{
Type: PrincipalEmbedSession,
ID: fmt.Sprintf("%d:%s:%s", tenantID, strings.TrimSpace(channelID), strings.TrimSpace(sessionID)),
}
}
// EmbedVisitorPrincipal identifies one anonymous embed visitor (browser).
func EmbedVisitorPrincipal(tenantID uint64, channelID, visitorID string) Principal {
return Principal{
Type: PrincipalEmbedVisitor,
ID: fmt.Sprintf("%d:%s:%s", tenantID, strings.TrimSpace(channelID), strings.TrimSpace(visitorID)),
}
}
// ValidateEmbedVisitorID checks the client-supplied anonymous visitor id.
func ValidateEmbedVisitorID(id string) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("empty embed visitor id")
}
if len(id) > 128 {
return fmt.Errorf("embed visitor id too long (max 128)")
}
for _, r := range id {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("embed visitor id contains invalid characters")
}
}
return nil
}
// MCPOAuthPrincipalFromContext resolves the OAuth token principal for ctx.
// Embed chat sessions map to a per-visitor principal when X-Embed-Visitor is
// present; otherwise OAuth falls back to the chat session principal.
func MCPOAuthPrincipalFromContext(ctx context.Context) Principal {
p, ok := PrincipalFromContext(ctx)
if !ok {
return Principal{}
}
p = p.Normalize()
if p.Type != PrincipalEmbedSession {
return p
}
visitorID := EmbedVisitorIDFromContext(ctx)
if visitorID == "" {
return p
}
parts := strings.SplitN(p.ID, ":", 3)
if len(parts) < 2 {
return p
}
tenantPart := strings.TrimSpace(parts[0])
channelPart := strings.TrimSpace(parts[1])
if tenantPart == "" || channelPart == "" {
return p
}
var tenantID uint64
if _, err := fmt.Sscanf(tenantPart, "%d", &tenantID); err != nil || tenantID == 0 {
return Principal{Type: PrincipalEmbedVisitor, ID: tenantPart + ":" + channelPart + ":" + visitorID}
}
return EmbedVisitorPrincipal(tenantID, channelPart, visitorID)
}
// SessionOwnerIDFromContext returns the sessions.user_id scope for the current
// caller. API external users and embed chat sessions use principal-derived IDs;
// tenant API keys are isolated per key id; MCP OAuth token storage uses
// MCPOAuthPrincipalFromContext (visitor-level for embed).
func SessionOwnerIDFromContext(ctx context.Context) string {
if p, ok := PrincipalFromContext(ctx); ok {
switch p.Type {
case PrincipalAPIExternalUser, PrincipalEmbedSession:
return p.StorageID()
case PrincipalAPITenant:
if scope, ok := TenantAPIKeyScopeFromContext(ctx); ok && scope.KeyID > 0 {
if tenantID, ok := TenantIDFromContext(ctx); ok && tenantID > 0 {
return fmt.Sprintf("%s%d:%d", SessionOwnerAPITenantKeyPrefix, tenantID, scope.KeyID)
}
}
}
}
userID, _ := UserIDFromContext(ctx)
return userID
}