Updated the author name and copyright statements across the gns3_copilot module. The name has been standardized from "Guobin Yue" to "Yue Guobin (岳国宾)" to reflect the correct author attribution including Chinese characters.
24 KiB
Enhance /me Endpoint with Groups, Pools, and ACEs
Document Status: Design Phase Priority: High Created: 2026-03-06 Related Docs: User-Selectable Group Default Config
Table of Contents
- Problem Description
- Data Model Analysis
- Solution Design
- Implementation Steps
- API Response Structure
- Testing Plan
Problem Description
Current Behavior
The /me endpoint only returns basic user information. Users cannot easily see:
- Which groups they belong to
- Which resource pools they have access to
- Their access control entries (ACEs)
User Needs
- Group Membership: Understand inherited configs and permissions
- Pool Access: Know which resource pools are available
- ACE Visibility: See what access control rules apply to them
Data Model Analysis
User Model Relationships
File: gns3server/db/models/users.py:38-52
class User(BaseTable):
__tablename__ = "users"
user_id = Column(GUID, primary_key=True, default=generate_uuid)
username = Column(String, unique=True, index=True)
email = Column(String, unique=True, index=True)
full_name = Column(String)
hashed_password = Column(String)
last_login = Column(DateTime)
is_active = Column(Boolean, default=True)
is_superadmin = Column(Boolean, default=False)
# Relationships
groups = relationship("UserGroup", secondary=user_group_map, back_populates="users")
acl_entries = relationship("ACE") # User's direct ACEs
ACE Model
File: gns3server/db/models/acl.py:28-46
class ACE(BaseTable):
__tablename__ = "acl"
ace_id = Column(GUID, primary_key=True, default=generate_uuid)
ace_type = Column(String) # "user" or "group"
path = Column(String) # e.g., "/pools/{pool_id}", "/projects"
propagate = Column(Boolean, default=True)
allowed = Column(Boolean, default=True)
user_id = Column(GUID, ForeignKey('users.user_id', ondelete="CASCADE"))
user = relationship("User", back_populates="acl_entries")
group_id = Column(GUID, ForeignKey('user_groups.user_group_id', ondelete="CASCADE"))
group = relationship("UserGroup", back_populates="acl_entries")
role_id = Column(GUID, ForeignKey('roles.role_id', ondelete="CASCADE"))
role = relationship("Role", back_populates="acl_entries")
Resource Pool Model
File: gns3server/db/models/pools.py:46-53
class ResourcePool(BaseTable):
__tablename__ = "resource_pools"
resource_pool_id = Column(GUID, primary_key=True, default=generate_uuid)
name = Column(String, unique=True, index=True)
resources = relationship("Resource", secondary=resource_pool_map, back_populates="resource_pools")
Key Relationships
User ────< UserGroup > (via user_group_map)
│
└───< ACE (user_id)
│
├── path = "/pools/{pool_id}" → ResourcePool
├── path = "/projects"
├── role → Role → Privilege
└── allowed (boolean)
UserGroup ────< ACE (group_id)
│
└─── users
Pool Path Format
From gns3server/db/repositories/rbac.py:326-327:
if ace_path.startswith("/pool"):
resource_pool_id = ace_path.split("/")[2]
Pool ACE Path Format: /pools/{resource_pool_id}
Solution Design
Approach
- Groups: Eager load via
selectinload(User.groups) - Pools: Extract from user and group ACEs where path starts with
/pools/ - ACEs: Aggregate user's direct ACEs and group ACEs
Response Structure
{
// ===== Basic User Info =====
"user_id": "uuid",
"username": "string",
"email": "string",
"full_name": "string",
"is_active": true,
"is_superadmin": false,
"last_login": "datetime",
"created_at": "datetime",
"updated_at": "datetime",
// ===== User Groups =====
"groups": [
{
"user_group_id": "uuid",
"name": "Developers",
"is_builtin": false,
"created_at": "datetime",
"updated_at": "datetime"
}
],
// ===== Accessible Pools =====
"pools": [
{
"resource_pool_id": "uuid",
"name": "Production Pool",
"access_source": "user", // "user" or "group"
"access_allowed": true
}
],
// ===== ACEs =====
"aces": [
{
"ace_id": "uuid",
"path": "/pools/{pool_id}",
"allowed": true,
"propagate": true,
"ace_type": "user", // "user" or "group"
"source_group_id": null, // null if ace_type is "user"
"source_group_name": null
}
]
}
Implementation Steps
Step 1: Update Schemas
File: gns3server/schemas/controller/users.py
from typing import List, Optional
from datetime import datetime
from pydantic import ConfigDict, EmailStr, BaseModel, Field, SecretStr
from uuid import UUID
from .base import DateTimeModelMixin
class UserGroup(BaseModel):
"""User group reference."""
user_group_id: UUID
name: str
is_builtin: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ResourcePoolInfo(BaseModel):
"""Resource pool info accessible to user."""
resource_pool_id: UUID
name: str
access_source: str = Field(..., description="'user' or 'group'")
access_allowed: bool = Field(..., description="Whether access is allowed")
model_config = ConfigDict(from_attributes=True)
class ACEInfo(BaseModel):
"""Access Control Entry info."""
ace_id: UUID
path: str
allowed: bool
propagate: bool
ace_type: str = Field(..., description="'user' or 'group'")
source_group_id: Optional[UUID] = Field(None, description="Group ID if from group ACE")
source_group_name: Optional[str] = Field(None, description="Group name if from group ACE")
model_config = ConfigDict(from_attributes=True)
class UserBase(BaseModel):
"""Common user properties."""
username: Optional[str] = Field(None, min_length=3, pattern="[a-zA-Z0-9_-]+$")
is_active: bool = True
email: Optional[EmailStr] = None
full_name: Optional[str] = None
class User(DateTimeModelMixin, UserBase):
user_id: UUID
last_login: Optional[datetime] = None
is_superadmin: bool = False
# NEW FIELDS
groups: List[UserGroup] = []
pools: List[ResourcePoolInfo] = []
aces: List[ACEInfo] = []
model_config = ConfigDict(from_attributes=True)
# Other existing schemas...
class UserCreate(UserBase):
username: str = Field(..., min_length=3, pattern="[a-zA-Z0-9_-]+$")
password: SecretStr = Field(..., min_length=8, max_length=100)
class UserUpdate(UserBase):
password: Optional[SecretStr] = Field(None, min_length=8, max_length=100)
class LoggedInUserUpdate(BaseModel):
password: Optional[SecretStr] = Field(None, min_length=8, max_length=100)
email: Optional[EmailStr] = None
full_name: Optional[str] = None
class Credentials(BaseModel):
username: str
password: str
Step 2: Update Repository Method
File: gns3server/db/repositories/users.py
from uuid import UUID
from typing import Optional, List, Dict, Any
from sqlalchemy import select, update, delete, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from .base import BaseRepository
import gns3server.db.models as models
from gns3server import schemas
from gns3server.services import auth_service
import logging
log = logging.getLogger(__name__)
class UsersRepository(BaseRepository):
# ... existing methods ...
async def get_user_with_details(
self,
user_id: UUID,
include_pools: bool = True,
include_aces: bool = True
) -> Optional[Dict[str, Any]]:
"""
Get user with groups, pools, and ACEs.
Args:
user_id: User UUID
include_pools: Whether to include accessible resource pools
include_aces: Whether to include ACEs
Returns:
Dictionary with user, groups, pools, and aces
"""
# Get user with groups eagerly loaded
query = select(models.User).where(
models.User.user_id == user_id
).options(selectinload(models.User.groups))
result = await self._db_session.execute(query)
user = result.scalars().first()
if not user:
return None
# Prepare response
response = {
"user": user,
"groups": list(user.groups),
"pools": [],
"aces": []
}
if not include_pools and not include_aces:
return response
# Get user's direct ACEs
user_aces_query = select(models.ACE).where(
models.ACE.user_id == user_id
)
user_aces_result = await self._db_session.execute(user_aces_query)
user_aces = user_aces_result.scalars().all()
# Get group ACEs (inherited from user's groups)
group_aces = []
for group in user.groups:
group_aces_query = select(models.ACE).where(
models.ACE.group_id == group.user_group_id
)
group_aces_result = await self._db_session.execute(group_aces_query)
group_aces.extend(group_aces_result.scalars().all())
# Process ACEs and extract pools
pool_ids_seen = set()
if include_aces:
# Add user ACEs
for ace in user_aces:
response["aces"].append({
"ace_id": ace.ace_id,
"path": ace.path,
"allowed": ace.allowed,
"propagate": ace.propagate,
"ace_type": "user",
"source_group_id": None,
"source_group_name": None
})
# Add group ACEs
for ace in group_aces:
response["aces"].append({
"ace_id": ace.ace_id,
"path": ace.path,
"allowed": ace.allowed,
"propagate": ace.propagate,
"ace_type": "group",
"source_group_id": ace.group_id,
"source_group_name": next((g.name for g in user.groups if g.user_group_id == ace.group_id), None)
})
if include_pools:
# Extract pools from ACEs
for ace in user_aces + group_aces:
if ace.path.startswith("/pools/") and ace.allowed:
try:
pool_id = UUID(ace.path.split("/")[2])
if pool_id not in pool_ids_seen:
# Get pool info
pool_query = select(models.ResourcePool).where(
models.ResourcePool.resource_pool_id == pool_id
)
pool_result = await self._db_session.execute(pool_query)
pool = pool_result.scalars().first()
if pool:
response["pools"].append({
"resource_pool_id": pool.resource_pool_id,
"name": pool.name,
"access_source": "user" if ace.user_id else "group",
"access_allowed": ace.allowed
})
pool_ids_seen.add(pool_id)
except (ValueError, IndexError) as e:
log.warning(f"Invalid pool path format: {ace.path}, error: {e}")
return response
Step 3: Update API Endpoint
File: gns3server/api/routes/controller/users.py
@router.get("/me", response_model=schemas.User)
async def get_logged_in_user(
current_user: schemas.User = Depends(get_current_active_user),
users_repo: UsersRepository = Depends(get_repository(UsersRepository))
) -> schemas.User:
"""
Get the current active user (including groups, pools, and ACEs).
Returns comprehensive user information including:
- Basic user profile
- Group memberships
- Accessible resource pools
- Access control entries (ACEs)
"""
# Fetch user with all details
user_details = await users_repo.get_user_with_details(
current_user.user_id,
include_pools=True,
include_aces=True
)
if not user_details:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Convert to schema
user = user_details["user"]
return schemas.User(
user_id=user.user_id,
username=user.username,
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_superadmin=user.is_superadmin,
last_login=user.last_login,
created_at=user.created_at,
updated_at=user.updated_at,
groups=[schemas.UserGroup.model_validate(g) for g in user_details["groups"]],
pools=[schemas.ResourcePoolInfo(**p) for p in user_details["pools"]],
aces=[schemas.ACEInfo(**a) for a in user_details["aces"]]
)
API Response Structure
Complete Example
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"username": "johndoe",
"email": "john@example.com",
"full_name": "John Doe",
"is_active": true,
"is_superadmin": false,
"last_login": "2026-03-06T10:30:00Z",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-03-06T10:30:00Z",
"groups": [
{
"user_group_id": "650e8400-e29b-41d4-a716-446655440001",
"name": "Developers",
"is_builtin": false,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
},
{
"user_group_id": "750e8400-e29b-41d4-a716-446655440002",
"name": "Administrators",
"is_builtin": true,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
],
"pools": [
{
"resource_pool_id": "850e8400-e29b-41d4-a716-446655440003",
"name": "Production Pool",
"access_source": "user",
"access_allowed": true
},
{
"resource_pool_id": "950e8400-e29b-41d4-a716-446655440004",
"name": "Development Pool",
"access_source": "group",
"access_allowed": true
}
],
"aces": [
{
"ace_id": "a50e8400-e29b-41d4-a716-446655440005",
"path": "/pools/850e8400-e29b-41d4-a716-446655440003",
"allowed": true,
"propagate": true,
"ace_type": "user",
"source_group_id": null,
"source_group_name": null
},
{
"ace_id": "b50e8400-e29b-41d4-a716-446655440006",
"path": "/projects",
"allowed": true,
"propagate": true,
"ace_type": "group",
"source_group_id": "650e8400-e29b-41d4-a716-446655440001",
"source_group_name": "Developers"
},
{
"ace_id": "c50e8400-e29b-41d4-a716-446655440007",
"path": "/pools/950e8400-e29b-41d4-a716-446655440004",
"allowed": true,
"propagate": true,
"ace_type": "group",
"source_group_id": "650e8400-e29b-41d4-a716-446655440001",
"source_group_name": "Developers"
}
]
}
Testing Plan
Unit Tests
Test get_user_with_details Repository Method
import pytest
from uuid import uuid4
@pytest.mark.asyncio
async def test_get_user_with_groups_only(db_session, test_user, test_group):
"""Test getting user with groups only."""
from gns3server.db.repositories.users import UsersRepository
repo = UsersRepository(db_session)
result = await repo.get_user_with_details(
test_user.user_id,
include_pools=False,
include_aces=False
)
assert result is not None
assert len(result["groups"]) > 0
assert result["groups"][0].name == test_group.name
assert result["pools"] == []
assert result["aces"] == []
@pytest.mark.asyncio
async def test_get_user_with_pools_and_aces(db_session, test_user, test_pool, test_ace):
"""Test getting user with pools and ACEs."""
from gns3server.db.repositories.users import UsersRepository
repo = UsersRepository(db_session)
result = await repo.get_user_with_details(
test_user.user_id,
include_pools=True,
include_aces=True
)
assert result is not None
assert len(result["pools"]) > 0
assert result["pools"][0]["name"] == test_pool.name
assert len(result["aces"]) > 0
assert result["aces"][0]["path"].startswith("/pools/")
@pytest.mark.asyncio
async def test_get_user_with_group_pools(db_session, test_user, test_group, test_group_pool, test_group_ace):
"""Test getting user with pools inherited from groups."""
from gns3server.db.repositories.users import UsersRepository
repo = UsersRepository(db_session)
result = await repo.get_user_with_details(
test_user.user_id,
include_pools=True,
include_aces=True
)
assert result is not None
# Should have pool from group ACE
group_pools = [p for p in result["pools"] if p["access_source"] == "group"]
assert len(group_pools) > 0
Integration Tests
Test /me Endpoint Response
def test_get_me_with_all_details(test_client, auth_token, test_user_with_groups_and_pools):
"""Test GET /me returns groups, pools, and ACEs."""
response = test_client.get(
"/v3/access/users/me",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 200
data = response.json()
# Verify basic user info
assert "user_id" in data
assert "username" in data
# Verify groups
assert "groups" in data
assert isinstance(data["groups"], list)
assert len(data["groups"]) > 0
assert "user_group_id" in data["groups"][0]
assert "name" in data["groups"][0]
# Verify pools
assert "pools" in data
assert isinstance(data["pools"], list)
if len(data["pools"]) > 0:
pool = data["pools"][0]
assert "resource_pool_id" in pool
assert "name" in pool
assert "access_source" in pool
assert pool["access_source"] in ["user", "group"]
# Verify ACEs
assert "aces" in data
assert isinstance(data["aces"], list)
if len(data["aces"]) > 0:
ace = data["aces"][0]
assert "ace_id" in ace
assert "path" in ace
assert "allowed" in ace
assert "ace_type" in ace
assert ace["ace_type"] in ["user", "group"]
def test_get_me_user_with_no_groups(test_client, auth_token, test_user_no_groups):
"""Test GET /me for user with no groups."""
response = test_client.get(
"/v3/access/users/me",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 200
data = response.json()
assert data["groups"] == []
# May still have pools and ACEs from direct user ACEs
Benefits
| Feature | Benefit |
|---|---|
| Groups in /me | Users see inherited configs and permissions |
| Pools in /me | Users know available resource pools without separate API call |
| ACEs in /me | Transparency - users see their access control rules |
| Single API Call | Frontend gets all user context in one request |
| No Privilege Required | Users can always see their own info |
Use Cases
1. Frontend User Profile Page
// Get complete user context
const response = await fetch('/v3/access/users/me', {
headers: { 'Authorization': `Bearer ${token}` }
});
const user = await response.json();
// Display groups
console.log('Member of:', user.groups.map(g => g.name));
// Display available pools
console.log('Accessible pools:', user.pools.map(p => p.name));
// Display ACE summary
console.log('ACEs:', user.aces.length);
2. LLM Config Selection UI
// User wants to select from inherited configs
const user = await fetchCurrentUser();
// Show which configs are from which groups
user.groups.forEach(group => {
console.log(`Configs from ${group.name}:`, getGroupConfigs(group.user_group_id));
});
3. Permission Troubleshooting
// User can't access a resource - why?
const user = await fetchCurrentUser();
// Check if user has pool access
const hasPoolAccess = user.pools.some(p => p.resource_pool_id === targetPoolId);
// Check ACEs
const relevantACEs = user.aces.filter(ace => ace.path.includes(resourcePath));
console.log('Relevant ACEs:', relevantACEs);
Performance Considerations
| Query | Complexity | Optimization |
|---|---|---|
| Get user with groups | 1 JOIN (eager load) | Uses selectinload |
| Get user ACEs | 1 query | Direct index lookup |
| Get group ACEs | N queries (one per group) | Could optimize with subquery |
| Get pool details | M queries (one per unique pool) | Could batch fetch |
Potential Optimization:
# Batch fetch all pools in one query
pool_ids = [extract_pool_id_from_ace(ace) for ace in all_aces]
pools_query = select(models.ResourcePool).where(
models.ResourcePool.resource_pool_id.in_(pool_ids)
)
pools_result = await self._db_session.execute(pools_query)
pools = {p.resource_pool_id: p for p in pools_result.scalars().all()}
Security Considerations
Data Exposure
| Data | Visibility | Rationale |
|---|---|---|
| Basic user info | User themselves | Already exposed in current /me |
| Groups | User themselves | User knows which groups they joined |
| Pools | User themselves | User knows which pools they can access |
| ACEs | User themselves | Transparency about access rules |
| Other users' data | Hidden | Not included in response |
Access Control
- Authentication Required: Must provide valid JWT token
- No Special Privilege: Users can always view their own data
- Filtering: Only returns data for the authenticated user
Future Enhancements
-
Roles: Add user's roles (derived from ACEs)
"roles": ["User", "Auditor"] -
Effective Privileges: Consolidated privilege list
"privileges": ["Project.Audit", "Node.Create"] -
Resource Counts: Summary of accessible resources
"resources_summary": { "projects_count": 5, "templates_count": 3 }
Code Changes Checklist
| File | Change Type | Description |
|---|---|---|
gns3server/schemas/controller/users.py |
Modify | Add UserGroup, ResourcePoolInfo, ACEInfo schemas; Update User schema |
gns3server/db/repositories/users.py |
Modify | Add get_user_with_details method |
gns3server/api/routes/controller/users.py |
Modify | Update /me endpoint to use new method |
Document Version: 1.0 Last Updated: 2026-03-06
License
Copyright © 2025 Yue Guobin (岳国宾)
This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License (CC BY-SA 4.0).
Summary
You are free to:
- Share — Copy and redistribute the material in any medium or format
- Adapt — Remix, transform, and build upon the material for any purpose
Under the following terms:
- Attribution — You must give appropriate credit to Yue Guobin (岳国宾), provide a link to the license, and indicate if changes were made.
- ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license (CC BY-SA 4.0).
Full license text: DESIGN_DOCS_LICENSE
