Entity Permission Extension
This document describes Wegent's unified resource sharing permission architecture, covering the current authorization mechanism, permission processing logic, extension architecture design, and how to integrate internal customized authorization systems via IExternalEntityResolver.
Overviewβ
Wegent's permission system is implemented based on the ResourceMember model and supports two member binding modes:
- Direct User Binding (
entity_type="user"): Add a specific user as a resource member - Entity Binding (
entity_type="namespace"or custom types): Bind an external entity (e.g., group, department) to a resource, and members within the entity automatically inherit access permissions
The core extension point for entity binding is the IExternalEntityResolver interface. The open-source version includes a built-in namespace type resolver, while internal deployments can register custom resolvers to integrate with enterprise organizational structures (e.g., departments, project teams).
Current Authorization Mechanismβ
Unified Role Systemβ
The system uses BaseRole as the single source of truth for role definitions:
| Role | Permission Level | Description |
|---|---|---|
| Owner | Highest | Full control, can delete resources and transfer ownership |
| Maintainer | High | Manage members and settings |
| Developer | Medium | Modify content |
| Reporter | Low | Read-only access |
| RestrictedAnalyst | Lowest | Restricted read-only access |
When role conflicts occur, the system automatically selects the highest permission role. This logic is implemented by has_permission() and get_highest_role().
Member Statusβ
ResourceMember.status has three states:
pending: Awaiting approvalapproved: Approved (only approved members participate in permission calculations)rejected: Rejected
Resource Member Modelβ
ResourceMember
βββ resource_type: str # Resource type, e.g., "KnowledgeBase"
βββ resource_id: int # Resource ID
βββ entity_type: str # "user" | "namespace" | custom type
βββ entity_id: str # Entity identifier
βββ role: str # Role value
βββ status: str # pending | approved | rejected
βββ user_id: int | None # Compatibility field, auto-synced for user type
Unique constraint: (resource_type, resource_id, entity_type, entity_id)
Ownership vs Authorizationβ
The permission system has two core concepts that are easily confused and must be clearly distinguished:
| Dimension | Ownership | Authorization |
|---|---|---|
| Determination basis | Resource's user_id field (e.g., Kind.user_id) | role field in ResourceMember records |
| Typical example | Creator of the knowledge base | A user added as a member and assigned the Owner role |
| Database representation | A field in the resource table row | A record in the resource_members table |
| Permission source | Naturally has full access without ResourceMember records | Must be obtained through member binding |
Key distinctions:
-
Creator is not equal to Owner in ResourceMember
- Creator is determined by
kb.user_id == user_id ResourceMember(role="Owner")is an authorized member, not necessarily the creator- The creator always has full permissions even without any
ResourceMemberrecords
- Creator is determined by
-
Both are independent sources during permission computation
- In
_compute_kb_access_core(),is_creatorandroles(from ResourceMember) are computed separately - Final result:
has_access = len(roles) > 0 or is_creator - Even without any authorization records, the creator always has access
- In
-
Ownership transfer only changes ownership, not authorization records
- After transfer, the new owner's
user_idis written to the resource table - Old
ResourceMemberrecords are not automatically deleted - If the old creator also has a
ResourceMember(role="Owner")record, it remains valid after transfer
- After transfer, the new owner's
-
Distinction in frontend display
PermissionSourceInfo.source_type="creator"indicates ownership sourcePermissionSourceInfo.source_type="direct"indicates direct authorization source- The creator also appears as one of the permission sources in the return value of
get_my_permission_sources()
Permission Processing Logicβ
Direct Permission Check Flowβ
The call chain of UnifiedShareService.check_permission():
check_permission(resource_id, user_id, required_role)
βββ 1. Query ResourceMember
β resource_type = {resource_type}
β entity_type = "user"
β entity_id = str(user_id)
β status = "approved"
β β Found direct binding
β β Check with has_permission(effective_role, required_role)
β β If satisfied, return True
β
βββ 2. No direct binding found β Fallback to check_entity_permission()
Entity Permission Fallback Flowβ
The call chain of check_entity_permission():
check_entity_permission(resource_id, user_id, required_role)
βββ 1. Query all approved bindings with entity_type != "user" for this resource
β Group by entity_type: {entity_type: [(entity_id, role), ...]}
β
βββ 2. Iterate each entity_type
β resolver = get_entity_resolver(entity_type)
β matched = resolver.match_entity_bindings(db, user_id, entity_type, entity_ids)
β β If matched is not empty
β β Check if the role corresponding to each matched entity_id satisfies required_role
β β If satisfied, return True
β
βββ 3. No match β Return False
Role Conflict Resolutionβ
When a user has both direct binding and entity binding, get_user_role() handles it as follows:
get_user_role(resource_id, user_id)
βββ direct_role = Role from direct user binding
β If direct_role == "Owner" β Return Owner directly
β
βββ entity_role = _get_highest_entity_role()
β Iterate all entity bindings
β Call match_entity_bindings() for each entity_type
β Take the highest role among matched entities
β
βββ If both direct_role and entity_role exist
β Return the higher permission of the two
Entity Permissions in List Queriesβ
When KnowledgeService.get_all_knowledge_bases_grouped() retrieves the knowledge base list, it also needs to get knowledge bases accessible via entity binding. This logic is implemented by _collect_entity_authorized_kbs():
_collect_entity_authorized_kbs(user_id, accessible_groups)
βββ Step 1: Handle namespace type (hardcoded optimization path)
β Convert accessible_groups to namespace IDs
β Query ResourceMember (entity_type="namespace", entity_id in namespace_ids)
β Collect these resources and their group, role information
β
βββ Step 2: Handle external entity types (via resolvers)
Iterate all registered entity_types (excluding "namespace", "user")
resolver = get_entity_resolver(entity_type)
resolved_kb_ids = resolver.get_resource_ids_by_entity(db, user_id, entity_type)
β Query ResourceMember rows corresponding to these KBs
β Filter out entity_ids that the user actually matches using match_entity_bindings()
β Collect role and group information
Group Namespace Entity Membersβ
In addition to KnowledgeBase, Namespaces (groups) themselves support entity member bindings. By binding external entities such as departments as group members, all users within that entity automatically gain group permissions and inherit access to downstream resources (knowledge bases, Teams) within the group.
Core flow:
get_effective_role_in_group(db, user_id, group_name)
βββ 1. Direct user binding
β Query ResourceMember (resource_type="Namespace", entity_type="user")
β β Return directly assigned role
β
βββ 2. Entity binding fallback
β Call _resolve_entity_roles_in_namespace()
β β Query all approved entity bindings for this group (entity_type != "user")
β β Group by entity_type, call resolver.match_entity_bindings()
β β Return list of matched roles
β
βββ 3. Parent group inheritance (only when 1 and 2 yield no results)
If group_name = "aaa/bbb"
β Recursively query effective role for "aaa"
When a user obtains permissions through multiple sources (direct member + entity member + parent group inheritance), the highest role wins. This is handled uniformly by get_highest_role().
Group entity member APIs:
GET /api/groups/{group_name}/entity-membersβ List group entity membersPOST /api/groups/{group_name}/entity-membersβ Add an entity member (Owner only)PUT /api/groups/{group_name}/entity-members/{entity_type}/{entity_id}β Update entity member role (Owner only)DELETE /api/groups/{group_name}/entity-members/{entity_type}/{entity_id}β Remove an entity member (Owner only)
Batch optimization:
iter_user_groups_with_roles() resolves all direct and entity-derived group memberships in a single pass, avoiding repeated resolver calls per group in list scenarios. get_effective_roles_in_groups() builds on this for batch role computation.
Shared entity role resolution:
resolve_entity_roles_for_resource() (in external_entity_resolver.py) provides a common entity role query implementation, reused by both _resolve_entity_roles_in_namespace() and UnifiedShareService._get_highest_entity_role() to eliminate duplication.
Extension Architecture Designβ
Share Service Layered Architectureβ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β API Layer β
β - /api/v1/share/members (add/remove/get members) β
β - /api/v1/knowledge (list with permissions) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Service Layer β
β UnifiedShareService (base_service.py) β
β βββ check_permission() / check_entity_permission() β
β βββ get_user_role() / _get_highest_entity_role() β
β βββ add_member() / remove_member() / get_members() β
β βββ get_my_permission_sources() β
β β
β KnowledgeShareService (knowledge_share_service.py) β
β βββ Knowledge base specific permission logic β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Resolver Layer β
β IExternalEntityResolver (external_entity_resolver.py) β
β βββ NamespaceEntityResolver (namespace_entity_resolver.py) β
β βββ [Custom Resolvers] Registered via register_entity_resolver() β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Model Layer β
β ResourceMember (resource_member.py) β
β βββ Polymorphic: (entity_type, entity_id) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
IExternalEntityResolver Interfaceβ
class IExternalEntityResolver(ABC):
@abstractmethod
def match_entity_bindings(
self, db, user_id, entity_type, entity_ids, user_context=None
) -> list[str]:
"""Return the list of entity IDs from entity_ids that the user actually matches"""
@abstractmethod
def get_resource_ids_by_entity(
self, db, user_id, entity_type, user_context=None
) -> list[int]:
"""Return all resource IDs accessible by the user via this entity type"""
@property
def requires_display_name_snapshot(self) -> bool:
"""Whether to persist display name snapshots (default True)"""
return True
def get_display_name(self, db, entity_id) -> Optional[str]:
"""Resolve display name for a single entity"""
return None
def batch_get_display_names(self, db, entity_ids) -> dict[str, str]:
"""Batch resolve display names (defaults to individual get_display_name calls)"""
Resolver Registration Mechanismβ
Resolvers are managed through a module-level registry and registered at application startup:
# app/services/share/__init__.py
register_entity_resolver("namespace", NamespaceEntityResolver)
# register_entity_resolver("department", DepartmentResolver) # Custom
After registration:
get_entity_resolver("namespace")returns a singleton instanceget_all_entity_types()returns the list of all registered types- Instances are cached; repeated calls return the same object
Extension Mechanismβ
Built-in Resolver: NamespaceEntityResolverβ
NamespaceEntityResolver handles bindings with entity_type="namespace". Core logic:
match_entity_bindings():
- Input:
entity_ids= List of bound namespace IDs (strings) - Query
ResourceMember(resource_type="Namespace", entity_type="user", entity_id=str(user_id), status="approved") - Return the subset of namespace IDs that the user actually belongs to
get_resource_ids_by_entity():
- First query which namespaces the user belongs to
- Then query which KnowledgeBases are bound to those namespaces
- Return deduplicated KB ID list
requires_display_name_snapshot: False
- Namespace names can be queried in real-time from the local
Namespacetable, no need to persist snapshots
Custom Resolver Implementation Stepsβ
Implementing a custom resolver requires the following steps:
Step 1: Create the resolver class
# app/services/share/department_resolver.py
from typing import Optional
from sqlalchemy.orm import Session
from app.services.share.external_entity_resolver import IExternalEntityResolver
class DepartmentResolver(IExternalEntityResolver):
"""Enterprise department permission resolver example"""
@property
def requires_display_name_snapshot(self) -> bool:
# Department names can be queried from enterprise API in real-time
return False
def match_entity_bindings(
self,
db: Session,
user_id: int,
entity_type: str,
entity_ids: list[str],
user_context: Optional[dict] = None,
) -> list[str]:
if entity_type != "department":
return []
# Query user's departments (example: from enterprise system or local cache table)
user_dept_ids = self._get_user_department_ids(user_id)
# Return intersection
return list(set(entity_ids) & set(user_dept_ids))
def get_resource_ids_by_entity(
self,
db: Session,
user_id: int,
entity_type: str,
user_context: Optional[dict] = None,
) -> list[int]:
if entity_type != "department":
return []
# Get all departments the user belongs to
user_dept_ids = self._get_user_department_ids(user_id)
if not user_dept_ids:
return []
# Query knowledge bases bound to these departments
from app.models.resource_member import MemberStatus, ResourceMember
from app.models.share_link import ResourceType
results = (
db.query(ResourceMember.resource_id)
.filter(
ResourceMember.resource_type == ResourceType.KNOWLEDGE_BASE.value,
ResourceMember.entity_type == "department",
ResourceMember.entity_id.in_(user_dept_ids),
ResourceMember.status == MemberStatus.APPROVED.value,
)
.all()
)
return list(set(r.resource_id for r in results))
def get_display_name(self, db: Session, entity_id: str) -> Optional[str]:
# Query department name from enterprise system or cache table
return self._query_department_name(entity_id)
def _get_user_department_ids(self, user_id: int) -> list[str]:
# Internal implementation: call enterprise org API or query local cache
pass
def _query_department_name(self, entity_id: str) -> Optional[str]:
pass
Step 2: Register the resolver
# app/services/share/__init__.py
from app.services.share.department_resolver import DepartmentResolver
register_entity_resolver("department", DepartmentResolver)
Step 3: Add resource members
Bind the department to a knowledge base via API or Service layer:
knowledge_share_service.add_member(
db,
resource_id=kb_id,
current_user_id=owner_id,
target_user_id=0, # target_user_id is 0 for entity types
role=MemberRole.Reporter,
entity_type="department",
entity_id="dept_123",
entity_display_name="Technology Department", # Optional, persisted if requires_display_name_snapshot=True
)
Usage Examplesβ
Complete Scenario: Sharing Knowledge Base by Departmentβ
Scenario: The company wants to share the "Product Specifications" knowledge base with all members of the "Product Department".
Implementation:
- Implement
DepartmentResolver(as shown in the code above) - In the knowledge base permission management, the Owner adds a department member:
- entity_type = "department"
- entity_id = "dept_product"
- role = "Developer"
- When a product department member logs in:
get_all_knowledge_bases_grouped()callsDepartmentResolver.get_resource_ids_by_entity()- Discovers that the member belongs to "dept_product", returns KB ID
- The knowledge base appears in the member's "Shared with Me" list
- When the member accesses the knowledge base:
check_permission()finds no direct binding- Falls back to
check_entity_permission() DepartmentResolver.match_entity_bindings()confirms the member belongs to "dept_product"- Permission check passes
Frontend Permission Source Displayβ
The frontend displays how users obtained their permissions via PermissionSourceInfo:
direct: Added directly as a memberentity: Obtained through an entity (department, group)link: Obtained through a share link
When the source is entity, the frontend calls get_display_name() or displays the entity_display_name snapshot to show the entity name.
Best Practicesβ
-
Keep resolvers lightweight:
match_entity_bindings()may be called on every permission check. Avoid heavyweight queries. Introduce local cache tables or Redis caching when necessary. -
Batch queries over individual queries: If the underlying system supports batch APIs, override
batch_get_display_names()to avoid N+1 problems. -
Display name snapshot strategy:
- If entity names come from reliable local data sources (e.g., Namespace table), set
requires_display_name_snapshot = False - If entity names come from external systems (e.g., enterprise APIs), set
requires_display_name_snapshot = True, letting the system persist snapshots when adding members
- If entity names come from reliable local data sources (e.g., Namespace table), set
-
Avoid circular dependencies: Resolver implementations should not import upper-layer Services like
KnowledgeShareService. Keep the Resolver Layer independent. -
Reuse user_context: In batch scenarios like list queries, the upper-layer Service can pass user profile data into
user_context, preventing the resolver from redundantly querying user information internally.