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,193 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user