Merge branch '3.1' into base-configs-3.0

This commit is contained in:
Jeremy Grossmann authored and GitHub committed 2026-08-08 22:11:39 +02:00
commit 909ccf8fcd
177 files changed
+16013 -1745

No files matched your search

+8 -1
View File
@@ -39,7 +39,7 @@ else:
async def ai_not_available(path: str = ""):
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-copilot]"
detail="AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]"
)
from . import controller
@@ -60,6 +60,7 @@ from . import roles
from . import acl
from . import pools
from . import privileges
from . import api_keys
from .dependencies.authentication import get_current_active_user
@@ -192,3 +193,9 @@ router.include_router(
dependencies=[Depends(get_current_active_user)],
tags=["GNS3 Copilot"]
)
router.include_router(
api_keys.router,
dependencies=[Depends(get_current_active_user)],
tags=["API Keys"]
)
@@ -0,0 +1,149 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
API routes for API key management.
"""
import secrets
import bcrypt
from uuid import uuid4, UUID
from fastapi import APIRouter, Depends, status, HTTPException
from gns3server import schemas
from gns3server.db.repositories.api_keys import ApiKeysRepository
from .dependencies.database import get_repository
from .dependencies.authentication import get_current_active_user
import logging
log = logging.getLogger(__name__)
router = APIRouter(prefix="/access/api-keys", tags=["API Keys"])
API_KEY_PREFIX = "gns3_"
API_KEY_BYTES = 32
def _generate_api_key(api_key_id: UUID = None) -> tuple[str, str, str, UUID]:
if api_key_id is None:
api_key_id = uuid4()
random_bytes = secrets.token_hex(API_KEY_BYTES)
raw_key = f"gns3_{api_key_id}_{random_bytes}"
# Only hash the random secret part, so auth can extract api_key_id and do O(1) lookup
key_hash = bcrypt.hashpw(random_bytes.encode(), bcrypt.gensalt()).decode()
key_prefix = raw_key[:8]
return raw_key, key_hash, key_prefix, api_key_id
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_api_key(
api_key_data: schemas.ApiKeyCreate,
current_user: schemas.User = Depends(get_current_active_user),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
) -> dict:
"""Create a new API key. The full key is returned only once."""
raw_key, key_hash, key_prefix, new_key_id = _generate_api_key()
db_key = await api_keys_repo.create_api_key(
api_key_id=new_key_id,
user_id=current_user.user_id,
name=api_key_data.name,
key_hash=key_hash,
key_prefix=key_prefix,
)
return {
"api_key_id": str(db_key.api_key_id),
"api_key": raw_key,
"name": db_key.name,
"key_prefix": db_key.key_prefix,
"created_at": db_key.created_at.isoformat() if db_key.created_at else None,
}
@router.get("")
async def list_api_keys(
current_user: schemas.User = Depends(get_current_active_user),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
) -> list[dict]:
"""List all API keys for the current user."""
keys = await api_keys_repo.get_api_keys_by_user(current_user.user_id)
return [
{
"api_key_id": str(k.api_key_id),
"name": k.name,
"key_prefix": k.key_prefix,
"created_at": k.created_at.isoformat() if k.created_at else None,
"last_used_at": k.last_used_at.isoformat() if k.last_used_at else None,
"revoked": k.revoked,
}
for k in keys
]
@router.post("/{api_key_id}/revoke", status_code=status.HTTP_200_OK)
async def revoke_api_key(
api_key_id: UUID,
current_user: schemas.User = Depends(get_current_active_user),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
) -> dict:
"""Revoke an API key. It will immediately stop working, but can be restored."""
key = await api_keys_repo.get_api_key(api_key_id)
if not key:
raise HTTPException(status_code=404, detail="API key not found")
if key.user_id != current_user.user_id:
raise HTTPException(status_code=403, detail="Cannot modify another user's API key")
await api_keys_repo.revoke_api_key(api_key_id)
return {"message": f"API key '{key.name}' revoked"}
@router.post("/{api_key_id}/restore", status_code=status.HTTP_200_OK)
async def restore_api_key(
api_key_id: UUID,
current_user: schemas.User = Depends(get_current_active_user),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
) -> dict:
"""Restore a previously revoked API key."""
key = await api_keys_repo.get_api_key(api_key_id)
if not key:
raise HTTPException(status_code=404, detail="API key not found")
if key.user_id != current_user.user_id:
raise HTTPException(status_code=403, detail="Cannot modify another user's API key")
await api_keys_repo.restore_api_key(api_key_id)
return {"message": f"API key '{key.name}' restored"}
@router.delete("/{api_key_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_api_key(
api_key_id: UUID,
current_user: schemas.User = Depends(get_current_active_user),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
) -> None:
"""Permanently delete an API key. Cannot be undone."""
key = await api_keys_repo.get_api_key(api_key_id)
if not key:
raise HTTPException(status_code=404, detail="API key not found")
if key.user_id != current_user.user_id:
raise HTTPException(status_code=403, detail="Cannot delete another user's API key")
await api_keys_repo.delete_api_key(api_key_id)
+20 -1
View File
@@ -18,7 +18,7 @@
API routes for computes.
"""
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Body, Depends, status
from typing import Any, List, Union, Optional
from uuid import UUID
@@ -165,6 +165,25 @@ async def docker_get_images(compute_id: Union[str, UUID]) -> List[schemas.Comput
return result
@router.post(
"/{compute_id}/docker/images/pull",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("Compute.Modify"))]
)
async def docker_pull_image(
compute_id: Union[str, UUID],
image: str = Body(..., embed=True, min_length=1, pattern=r"^\S+$")
) -> None:
"""
Pull or update a Docker image on a compute.
Required privilege: Compute.Modify
"""
compute = Controller.instance().get_compute(str(compute_id))
await compute.forward("POST", "docker", "images/pull", data={"image": image})
@router.get("/{compute_id}/virtualbox/vms", response_model=List[schemas.ComputeVirtualBoxVM])
async def virtualbox_vms(compute_id: Union[str, UUID]) -> List[schemas.ComputeVirtualBoxVM]:
"""
@@ -14,13 +14,18 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
import logging
import bcrypt
from fastapi import Request, Query, Depends, HTTPException, WebSocket, status
from fastapi.security import OAuth2PasswordBearer
from typing import Optional
from uuid import UUID
from gns3server import schemas
import gns3server.db.models as models
from gns3server.db.repositories.api_keys import ApiKeysRepository
from gns3server.db.repositories.users import UsersRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.services import auth_service
@@ -30,12 +35,25 @@ log = logging.getLogger(__name__)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v3/access/users/login", auto_error=False)
def _reject_refresh_token(token_data) -> None:
"""Reject tokens with type == 'refresh' — they must not grant API access."""
if token_data.token_use == "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh tokens cannot be used for API access",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_user_from_token(
bearer_token: str = Depends(oauth2_scheme),
user_repo: UsersRepository = Depends(get_repository(UsersRepository)),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
token: Optional[str] = Query(None, include_in_schema=False)
) -> schemas.User:
if bearer_token:
# bearer token is used first, then any token passed as a URL parameter
token = bearer_token
@@ -47,7 +65,35 @@ async def get_user_from_token(
headers={"WWW-Authenticate": "Bearer"},
)
# API Key authentication — format: gns3_<api_key_id>_<random_secret>
# Direct lookup by UUID avoids O(n) scan of all keys.
if token.startswith("gns3_"):
parts = token.split("_", 2)
if len(parts) != 3:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key format")
try:
key_id = UUID(parts[1])
except ValueError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key format")
secret = parts[2]
db_key = await api_keys_repo.get_api_key(key_id)
if not db_key or db_key.revoked:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
if not await asyncio.to_thread(bcrypt.checkpw, secret.encode(), db_key.key_hash.encode()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
await api_keys_repo.update_last_used(db_key.api_key_id)
user = await user_repo.get_user(db_key.user_id)
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not an active user",
headers={"WWW-Authenticate": "Bearer"},
)
return user
# JWT authentication
token_data = auth_service.get_token_data(token)
_reject_refresh_token(token_data)
user = await user_repo.get_user_by_username(token_data.username)
if user is None:
raise HTTPException(
@@ -103,6 +149,7 @@ async def get_current_active_user_from_websocket(
try:
token_data = auth_service.get_token_data(token)
_reject_refresh_token(token_data)
user = await user_repo.get_user_by_username(token_data.username)
if user is None:
@@ -22,6 +22,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from gns3server.db.repositories.base import BaseRepository
async def get_db_session(request: HTTPConnection) -> AsyncSession:
async with AsyncSession(request.app.state._db_engine, expire_on_commit=False) as session:
@@ -52,6 +52,10 @@ def has_privilege_on_websocket(
current_user: schemas.User = Depends(get_current_active_user_from_websocket),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
):
# Authentication may have failed and closed the socket inside the auth
# dependency, returning None — bail out before touching the user object.
if current_user is None:
return None
if not current_user.is_superadmin:
path = re.sub(r"^/v[0-9]", "", websocket.url.path) # remove the prefix (e.g. "/v3") from URL path
log.debug(f"Checking user {current_user.username} has privilege {privilege_name} on '{path}'")
+97 -2
View File
@@ -27,12 +27,12 @@ from fastapi import APIRouter, Depends, Request, status, WebSocket
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.encoders import jsonable_encoder
from typing import List, Union
from uuid import UUID
from uuid import UUID, uuid4
from gns3server.controller import Controller
from gns3server.controller.controller_error import ControllerError
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.controller.link import Link
from gns3server.controller.link import Link, _UNSET
from gns3server.utils.http_client import HTTPClient
from gns3server.utils.port_allocator import link_id_to_port
from gns3server.utils.websocket_to_websocket import websocket_proxy
@@ -424,6 +424,101 @@ async def web_wireshark_websocket(
pass
@router.get(
"/{link_id}/markers",
dependencies=[Depends(has_privilege("Link.Audit"))]
)
async def get_markers(link: Link = Depends(dep_link)) -> dict:
"""
Return all traffic-insight markers configured on this link.
Required privilege: Link.Audit
"""
return link.markers
@router.post(
"/{link_id}/markers",
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Link.Modify"))]
)
async def create_marker(
marker_data: schemas.MarkerCreate,
link: Link = Depends(dep_link)
) -> dict:
"""
Attach a traffic-insight marker to the link.
On BPF match uBridge emits MARK signals and appends packets to a pcap.
Required privilege: Link.Modify
"""
# Auto-generate a link-unique name when the caller omits one. The short
# uuid suffix avoids the collision that `marker-{link.id[:8]}` alone would
# cause on the second anonymous marker on the same link (start_marker
# rejects duplicate names).
if marker_data.name and marker_data.name.lower().startswith("global"):
raise ControllerError('Names starting with "global" are reserved for inherited markers')
name = marker_data.name or f"marker-{link.id[:8]}-{uuid4().hex[:4]}"
await link.start_marker(
name=name,
bpf=marker_data.bpf,
tag=marker_data.tag,
direction=marker_data.direction,
capture_node_id=marker_data.capture_node_id,
color=marker_data.color,
highlight_duration=marker_data.highlight_duration,
data_link_type=marker_data.data_link_type,
)
return link.markers.get(name, {})
@router.delete(
"/{link_id}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("Link.Modify"))]
)
async def delete_marker(
marker_name: str,
link: Link = Depends(dep_link)
) -> None:
"""
Remove a traffic-insight marker from the link.
Required privilege: Link.Modify
"""
await link.stop_marker(marker_name)
@router.put(
"/{link_id}/markers/{marker_name}",
dependencies=[Depends(has_privilege("Link.Modify"))]
)
async def update_marker(
marker_name: str,
marker_data: schemas.MarkerUpdate,
link: Link = Depends(dep_link)
) -> dict:
"""
Update a traffic-insight marker (change BPF, tag, or enabled).
Required privilege: Link.Modify
"""
await link.update_marker(
name=marker_name,
bpf=marker_data.bpf if marker_data.bpf else None,
tag=marker_data.tag,
direction=marker_data.direction if "direction" in marker_data.model_fields_set else _UNSET,
color=marker_data.color,
enabled=marker_data.enabled,
highlight_duration=marker_data.highlight_duration,
)
return link.markers.get(marker_name, {})
@router.get(
"/{link_id}/iface",
response_model=Union[schemas.UDPPortInfo, schemas.EthernetPortInfo],
+82 -11
View File
@@ -24,6 +24,7 @@ import ipaddress
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, Request, Response, status, Query, HTTPException
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse
from fastapi.routing import APIRoute
from typing import List, Callable, Optional
from uuid import UUID
@@ -242,14 +243,25 @@ async def reload_all_nodes(project: Project = Depends(dep_project)) -> None:
raise
# Node types that need live host interface data from compute
_HOST_INTERFACE_NODE_TYPES = {"cloud", "nat"}
@router.get("/{node_id}", response_model=schemas.Node, dependencies=[Depends(has_privilege("Node.Audit"))])
def get_node(node: Node = Depends(dep_node)) -> schemas.Node:
async def get_node(node: Node = Depends(dep_node)) -> schemas.Node:
"""
Return a node from a given project.
Required privilege: Node.Audit
"""
if node.node_type in _HOST_INTERFACE_NODE_TYPES:
try:
response = await node.get()
await node.parse_node_response(response.json)
except Exception:
# If compute is unreachable, still return cached data
log.warning(f"Could not refresh node {node.id} from compute, returning cached data")
return node.asdict()
@@ -523,17 +535,30 @@ async def delete_disk_image(
@router.get("/{node_id}/files", response_model=List[schemas.NodeFile], dependencies=[Depends(has_privilege("Node.Audit"))])
async def list_node_files(node: Node = Depends(dep_node)) -> List[schemas.NodeFile]:
async def list_node_files(
node: Node = Depends(dep_node),
path: str = Query("", description="Subdirectory path within node directory"),
recursive: bool = Query(False, description="Recursively list all files")
) -> List[schemas.NodeFile]:
"""
List files in a node directory with detailed metadata.
By default lists only the current directory level (non-recursive).
Use recursive=true for a full recursive listing.
Required privilege: Node.Audit
"""
node_type = node.node_type
url = f"/projects/{node.project.id}/nodes/{node_type}/{node.id}/files"
params = {}
if path:
params["path"] = path
if recursive:
params["recursive"] = "true"
res = await node.compute.http_query(
"GET",
f"/projects/{node.project.id}/nodes/{node_type}/{node.id}/files",
"GET", url,
params=params if params else None,
timeout=None
)
return res.json
@@ -550,14 +575,32 @@ async def get_file(file_path: str, node: Node = Depends(dep_node)) -> Response:
path = force_unix_path(file_path)
# Raise error if user try to escape
if path[0] == ".":
if path.startswith(".."):
raise ControllerForbiddenError("It is forbidden to get a file outside the project directory")
node_type = node.node_type
path = f"/project-files/{node_type}/{node.id}/{path}"
res = await node.compute.http_query("GET", f"/projects/{node.project.id}/files{path}", timeout=None, raw=True)
return Response(res.body, media_type="application/octet-stream", status_code=res.status)
compute_resp = await node.compute.http_query(
"GET", f"/projects/{node.project.id}/files{path}",
timeout=None, stream=True
)
async def streamer():
try:
async for chunk in compute_resp.content.iter_chunked(65536):
yield chunk
except (IOError, OSError, asyncio.TimeoutError) as e:
log.error(f"Error streaming file '{path}' from compute: {e}")
raise
finally:
compute_resp.close()
return StreamingResponse(
streamer(),
media_type="application/octet-stream",
status_code=compute_resp.status,
)
@router.post(
@@ -575,15 +618,43 @@ async def post_file(file_path: str, request: Request, node: Node = Depends(dep_n
path = force_unix_path(file_path)
# Raise error if user try to escape
if path[0] == ".":
if path.startswith(".."):
raise ControllerForbiddenError("Cannot write outside the node directory")
node_type = node.node_type
path = f"/project-files/{node_type}/{node.id}/{path}"
data = await request.body() # FIXME: are we handling timeout or large files correctly?
await node.compute.http_query("POST", f"/projects/{node.project.id}/files{path}", data=data, timeout=None, raw=True)
# FIXME: response with correct status code (from compute)
# Stream request body directly to compute node
await node.compute.http_query(
"POST", f"/projects/{node.project.id}/files{path}",
data=request.stream(), timeout=None
)
@router.delete(
"/{node_id}/files/{file_path:path}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("Node.Modify"))]
)
async def delete_node_file(file_path: str, node: Node = Depends(dep_node)) -> None:
"""
Delete a file from the node directory.
Required privilege: Node.Modify
"""
path = force_unix_path(file_path)
if path.startswith(".."):
raise ControllerForbiddenError("It is forbidden to delete a file outside the project directory")
node_type = node.node_type
path = f"/project-files/{node_type}/{node.id}/{path}"
await node.compute.http_query(
"DELETE", f"/projects/{node.project.id}/files{path}",
timeout=None
)
@router.websocket("/{node_id}/console/ws")
+159 -1
View File
@@ -40,6 +40,7 @@ from uuid import UUID
from gns3server import schemas
from gns3server.controller import Controller
from gns3server.controller.project import Project
from gns3server.controller.link import _UNSET
from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError
from gns3server.controller.import_project import import_project as import_controller_project
from gns3server.controller.export_project import export_project as export_controller_project
@@ -203,6 +204,161 @@ def get_project_stats(project: Project = Depends(dep_project)) -> dict:
return project.stats()
@router.get("/{project_id}/markers", dependencies=[Depends(has_privilege("Project.Audit"))])
def get_project_markers(project: Project = Depends(dep_project)) -> dict:
"""
Return all traffic-insight markers across every link in the project.
Each entry is keyed ``"{link_id}/{marker_name}"`` and carries the
marker's BPF, tag, color, enabled flag, plus its parent ``link_id``
and capture-side ``node_id`` for frontend filtering / grouping.
Required privilege: Project.Audit
"""
return project.markers
# ---------------------------------------------------------------------------
# Project-level marker definitions (global rules inherited by every link)
# ---------------------------------------------------------------------------
@router.get(
"/{project_id}/marker-definitions",
dependencies=[Depends(has_privilege("Project.Audit"))]
)
def get_marker_definitions(project: Project = Depends(dep_project)) -> dict:
"""
Return all project-level marker definitions with their bound link IDs.
Required privilege: Project.Audit
"""
result = {}
for name, d in project.marker_definitions.items():
# Collect which links currently carry an inherited copy.
bound = [
lid for lid, link in project.links.items()
if f"global-{name}" in link.markers
and link.markers[f"global-{name}"].get("inherited_from") == name
]
result[name] = {**d, "link_ids": bound}
return result
@router.post(
"/{project_id}/marker-definitions",
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Project.Modify"))]
)
async def create_marker_definition(
def_data: schemas.MarkerDefinitionCreate,
project: Project = Depends(dep_project)
) -> dict:
"""
Create a project-level marker definition and fan out to every link.
Required privilege: Project.Modify
"""
if def_data.name and def_data.name.lower().startswith("global"):
raise ControllerError('Names starting with "global" are reserved for inherited markers')
name = def_data.name or f"def-{project.id[:8]}"
await project.create_marker_definition(
name=name,
bpf=def_data.bpf,
tag=def_data.tag,
direction=def_data.direction,
color=def_data.color,
highlight_duration=def_data.highlight_duration,
data_link_type=def_data.data_link_type,
)
return project.marker_definitions.get(name, {})
@router.put(
"/{project_id}/marker-definitions/{def_name}",
dependencies=[Depends(has_privilege("Project.Modify"))]
)
async def update_marker_definition(
def_name: str,
def_data: schemas.MarkerDefinitionCreate,
project: Project = Depends(dep_project)
) -> dict:
"""
Update a marker definition and sync all inherited copies on every link.
Required privilege: Project.Modify
"""
await project.update_marker_definition(
name=def_name,
bpf=def_data.bpf if def_data.bpf else None,
tag=def_data.tag,
direction=def_data.direction if "direction" in def_data.model_fields_set else _UNSET,
color=def_data.color,
highlight_duration=def_data.highlight_duration,
data_link_type=def_data.data_link_type if "data_link_type" in def_data.model_fields_set else _UNSET,
)
return project.marker_definitions.get(def_name, {})
@router.post(
"/{project_id}/marker-definitions/{def_name}/pause",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("Project.Modify"))]
)
async def pause_marker_definition(
def_name: str,
project: Project = Depends(dep_project)
) -> None:
"""
Pause a definition: toggle off every inherited ``global-{def_name}`` copy
on every link (uBridge ``enable_packet_filter off``, instant — no NIO
rebuild). The definition's ``paused`` flag is persisted, so links created
later inherit it already paused.
Required privilege: Project.Modify
"""
await project.pause_marker_definition(def_name)
@router.post(
"/{project_id}/marker-definitions/{def_name}/resume",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("Project.Modify"))]
)
async def resume_marker_definition(
def_name: str,
project: Project = Depends(dep_project)
) -> None:
"""Resume a paused definition (toggle on every inherited copy).
Required privilege: Project.Modify
"""
await project.resume_marker_definition(def_name)
@router.delete(
"/{project_id}/marker-definitions/{def_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("Project.Modify"))]
)
async def delete_marker_definition(
def_name: str,
project: Project = Depends(dep_project)
) -> None:
"""
Delete a marker definition and remove all inherited copies from every link.
Required privilege: Project.Modify
"""
await project.delete_marker_definition(def_name)
@router.post(
"/{project_id}/close",
status_code=status.HTTP_204_NO_CONTENT,
@@ -611,9 +767,11 @@ async def create_node_from_template(
"""
template = await TemplatesService(templates_repo).get_template(template_id)
controller = Controller.instance()
project = controller.get_project(str(project_id))
node = await project.add_node_from_template(
template, x=template_usage.x, y=template_usage.y, compute_id=template_usage.compute_id
template, x=template_usage.x, y=template_usage.y, name=template_usage.name, compute_id=template_usage.compute_id
)
return node.asdict()
+47 -2
View File
@@ -67,7 +67,8 @@ async def login(
token = schemas.Token(
access_token=auth_service.create_access_token(user.username, token_version=user.token_version),
token_type="bearer"
token_type="bearer",
refresh_token=auth_service.create_refresh_token(user.username, token_version=user.token_version),
)
return token
@@ -92,11 +93,55 @@ async def authenticate(
token = schemas.Token(
access_token=auth_service.create_access_token(user.username, token_version=user.token_version),
token_type="bearer"
token_type="bearer",
refresh_token=auth_service.create_refresh_token(user.username, token_version=user.token_version),
)
return token
@router.post("/refresh", response_model=schemas.Token)
async def refresh_access_token(
request: schemas.RefreshTokenRequest,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
) -> schemas.Token:
"""
Exchange a refresh token for a new access token.
Public endpoint — the refresh token itself proves identity. Respects the
user's token_version, so logout (which increments it) invalidates all
outstanding refresh tokens. Refresh tokens are stateless JWTs with a
longer expiry (default 30 days). Stolen tokens remain valid until their
`exp` or until logout — no replay protection without a server-side table.
"""
token_data = auth_service.get_token_data(request.refresh_token)
if token_data.token_use != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
headers={"WWW-Authenticate": "Bearer"},
)
user = await users_repo.get_user_by_username(token_data.username)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if token_data.token_version != user.token_version:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Token has been revoked for '{token_data.username}'",
headers={"WWW-Authenticate": "Bearer"},
)
return schemas.Token(
access_token=auth_service.create_access_token(user.username, token_version=user.token_version),
token_type="bearer",
refresh_token=auth_service.create_refresh_token(user.username, token_version=user.token_version),
)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
current_user: schemas.User = Depends(get_current_active_user),