Compare commits

...
Author SHA1 Message Date
Jeremy Grossmann 8cd82f3b6c Merge pull request #3073 from GNS3/dependabot/pip/3.1/langchain-anthropic-gte-1.7.10
Update langchain-anthropic requirement from >=1.3.4 to >=1.7.10
2026-11-12 12:55:22 +01:00
Jeremy Grossmann 89b88a5e88 Merge pull request #3072 from GNS3/dependabot/pip/3.1/websockets-17.4
Bump websockets from 16.1.1 to 17.4
2026-11-12 12:55:08 +01:00
Jeremy Grossmann c24e332d79 Update requirements.txt 2026-11-12 12:44:42 +01:00
Jeremy Grossmann dbee7110da Merge pull request #3071 from GNS3/dependabot/pip/3.1/sqlalchemy-2.1.8
Bump sqlalchemy from 2.1.6 to 2.1.8
2026-11-12 12:39:26 +01:00
Jeremy Grossmann 5e30ce4b13 Merge pull request #3070 from GNS3/dependabot/pip/3.1/nornir-gte-3.6.1
Update nornir requirement from >=3.6.0 to >=3.6.1
2026-11-12 12:39:08 +01:00
Jeremy Grossmann e142aa68bd Merge pull request #3069 from GNS3/dependabot/pip/3.1/paramiko-gte-5.1.0
Update paramiko requirement from >=5.0.0 to >=5.1.0
2026-11-12 12:38:50 +01:00
dependabot[bot] 4f79fc21db Update langchain-anthropic requirement from >=1.3.4 to >=1.7.10
Updates the requirements on [langchain-anthropic](https://github.com/langchain-ai/langchain) to permit the latest version.
- [Release notes](https://github.com/langchain-ai/langchain/releases)
- [Commits](https://github.com/langchain-ai/langchain/compare/langchain-anthropic==1.3.4...langchain-anthropic==1.7.10)

---
updated-dependencies:
- dependency-name: langchain-anthropic
  dependency-version: 1.7.10
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-12 11:23:07 +00:00
dependabot[bot] 353ca2ec93 Bump websockets from 16.1.1 to 17.4
Bumps [websockets](https://github.com/python-websockets/websockets) from 16.1.1 to 17.4.
- [Release notes](https://github.com/python-websockets/websockets/releases)
- [Commits](https://github.com/python-websockets/websockets/compare/16.1.1...17.4)

---
updated-dependencies:
- dependency-name: websockets
  dependency-version: '17.4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-12 11:22:56 +00:00
dependabot[bot] dd429e9385 Bump sqlalchemy from 2.1.6 to 2.1.8
Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 2.1.6 to 2.1.8.
- [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases)
- [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst)
- [Commits](https://github.com/sqlalchemy/sqlalchemy/commits)

---
updated-dependencies:
- dependency-name: sqlalchemy
  dependency-version: 2.1.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-12 11:22:49 +00:00
dependabot[bot] 6891e7e66f Update nornir requirement from >=3.6.0 to >=3.6.1
Updates the requirements on [nornir](https://github.com/nornir-automation/nornir) to permit the latest version.
- [Release notes](https://github.com/nornir-automation/nornir/releases)
- [Changelog](https://github.com/nornir-automation/nornir/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/nornir-automation/nornir/compare/v3.6.0...v3.6.1)

---
updated-dependencies:
- dependency-name: nornir
  dependency-version: 3.6.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-12 11:22:40 +00:00
dependabot[bot] 984bf4232d Update paramiko requirement from >=5.0.0 to >=5.1.0
Updates the requirements on [paramiko](https://github.com/paramiko/paramiko) to permit the latest version.
- [Commits](https://github.com/paramiko/paramiko/compare/5.0.0...5.1.0)

---
updated-dependencies:
- dependency-name: paramiko
  dependency-version: 5.1.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-12 11:22:34 +00:00
Jeremy Grossmann ce8d5591a1 Merge pull request #3062 from yueguobin/feat/docker-image-upload
Allow uploading, deleting and pulling Docker images on computes
2026-11-07 16:49:26 +01:00
YueGuobin c066434959 refactor: move Docker image management to its own module
pull, load, list and delete images, the image id translation bookkeeping
and their helpers move from the Docker manager to DockerImagesMixin in
docker_images.py. The manager keeps the daemon connection, the HTTP layer
and the node factory. The mixin is listed before BaseManager so its
list_images overrides the file-system one. The C901 ruff exemption moves
with pull_image.
2026-11-07 19:36:00 +08:00
YueGuobin 653ea386a5 fix: use the OCI created label when the daemon reports no build time
Images built for reproducibility, such as the Ubuntu images assembled
with rockcraft, carry no created timestamp in their config and Docker
reports 0, shown as 1970-01-01. Fall back to the
org.opencontainers.image.created label in list_images so the image list
shows the real build time.
2026-11-07 19:27:55 +08:00
YueGuobin 076c949a4e fix: default tagless Docker image pulls to the latest tag
The daemon pulls every tag of a repository when the tag parameter is
empty. The docker CLI never hits this because it adds :latest before
calling the API, so only raw API callers do. Pulling "debian" through
the API downloaded dozens of images until the registry rate limit
stopped the pull.

Pass tag=latest when the reference has no tag and no digest. Only a
':' after the last '/' counts as a tag separator: before it, it is a
registry port (e.g. registry:5000/repo).

This covers both the /docker/images/pull route and the automatic pull
fallback when a node image is missing.
2026-11-07 14:43:13 +08:00
Jeremy Grossmann 01c20adac5 Merge pull request #3065 from yueguobin/feat/project-privileges-endpoint
Add endpoint for the current user's privileges on a project
2026-11-07 05:31:29 +01:00
Jeremy Grossmann fcf8490d90 Merge pull request #3064 from yueguobin/feat/rbac-in-memory
Keep RBAC state in memory for permission checks
2026-11-07 05:27:01 +01:00
YueGuobin f8eedae9fc feat: add endpoint for the current user's privileges on a project
GET /projects/{project_id}/me/privileges returns the effective privileges
of the logged in user, evaluated on the project's resource path. Unlike
/access/users/me/privileges, ACEs scoped to the project and resource
pools containing it are reflected. This gives clients what they need to
show or hide buttons inside a project view.
2026-11-07 00:27:32 +08:00
YueGuobin 0914f44273 fix: use the /access prefix when cleaning up ACEs on delete
The users, groups and roles routes are mounted under /access and ACE
paths follow the real route paths. The cleanup after deleting a user,
group or role looked at /users, /groups and /roles, so it never matched
any ACE. Typed ACEs were still removed by the foreign key cascade, but
an ACE on the resource path granted to another principal survived the
deletion.
2026-11-07 00:02:20 +08:00
YueGuobin f87f20cb27 fix: commit the bulk ACE deletion in the repository
delete_all_ace_starting_with_path was the only RBAC write method
without a commit. The request session is only closed at teardown, so
the deletion was rolled back and the ACEs came back on the next
snapshot reload. Tests did not catch it because they share one
session for the whole class.
2026-11-06 23:55:12 +08:00
YueGuobin 2244d730bb perf: keep RBAC state in memory for permission checks
Permission checks ran several queries per request, including a full
scan of the resources table. The controller already keeps projects,
computes and notifications in process memory: apply the same idea to
the RBAC tables.

Roles, ACEs, group memberships and resource pool memberships are
loaded into a process-wide snapshot. Checks run against the snapshot
in memory. Repository write methods mark the snapshot dirty and the
next read reloads it from the database, so privilege changes apply
right away. The database stays the source of truth.

The evaluation is a faithful port of the previous SQL version, quirks
included (reverse path traversal, deny raising PermissionError, pool
checks with the "/pool" prefix, group ACEs in natural order).

The two RBAC tests that mutated ORM objects directly now write through
the repository, like the rest of the code. The /projects ACE they
relied on was created by another test, so they were order dependent:
they now create it when missing.
2026-11-06 23:47:29 +08:00
YueGuobin c846c2ae90 fix: refuse Docker image deletion while templates reference the image
A deletion the user must confirm belongs in the response of the delete
request, not in a notification sent after the fact. The controller now
refuses with a 409 (code image_referenced_by_templates, the template
names in details) while any Docker template references the image,
before the compute is touched. force=true confirms the deletion.

The docker.image.deleted notification stays as a change signal so
clients can refresh their image lists.
2026-11-06 22:15:52 +08:00
YueGuobin de986e890e feat: name nodes and templates in Docker image delete conflicts
The compute translates the container ids in a daemon delete conflict
into the GNS3 node and project behind each container. Leftover
containers from closed projects and containers created outside GNS3
are named as such.

The controller lists the templates referencing the image in the
conflict error, matched through the canonical image name. After a
successful deletion it emits a docker.image.deleted notification with
those templates: their nodes can no longer start.
2026-11-06 22:05:52 +08:00
YueGuobin 04a8ba06a8 fix: start pull events when the pull begins and reject unsafe image names
The pull started event was emitted by the route before the pull took
its per compute lock, so a request queued behind another pull claimed
to be running while it was still waiting. The pull now reports the
started state itself, once it owns the lock.

Deleting an image interpolates the name into the daemon API path. The
pattern on the path parameter limits it to the characters of an image
reference and validate_docker_image_name also rejects "..", so a name
cannot escape that path. Applied on both the controller and compute
routes.
2026-11-06 15:42:51 +08:00
YueGuobin d5d25eba06 fix: stop reporting a missing image as a network problem
A 404 from the registry means the image name is wrong or the registry
needs authentication, but the pull wrapped every error with "please
check your Internet connection". Say what actually failed and keep the
network advice for the other errors.
2026-11-06 15:23:34 +08:00
YueGuobin 8c98142d26 feat: report Docker pull progress and add an idle timeout
A pull now reports docker.image.pull notifications on the controller
stream: started, throttled downloading events with the layer counts
and the latest daemon message, then completed or failed. An optional
progress_id is echoed in every event so a client can correlate them
with its own request. Image operations belong to a compute, not to a
project, so the events go to the controller stream and reach clients
that have no project open.

The pull also has an idle socket read timeout of 60 seconds. Without
it an unreachable registry left the pull, the compute request and the
client request hanging forever with no feedback at all. The timeout
is idle time, not total time: the daemon streams progress during the
whole pull, so a minute without data means the registry is gone. When
it fires, the daemon connection closes and Docker cancels the pull.

Docker.http_query now accepts an aiohttp ClientTimeout object so a
caller can ask for a policy finer than a total timeout.
2026-11-06 15:03:34 +08:00
YueGuobin 424ba919d8 fix: skip non-image files when scanning the image roots
The image inventory scanned every file under the image roots and
reported anything without an image header as an invalid image. Files
users legitimately keep next to their images, such as the Cisco IOU
keygen script, made every scan report errors and the sync job partial.

Extend the existing ignore filter with the extensions of scripts,
documentation and certificates. They are skipped by both the scan and
the file watcher, so they no longer trigger rescans either. Real
corrupted images are still reported.
2026-11-06 14:36:03 +08:00
YueGuobin 54cfcef6e2 fix: send the docker force query parameter as a string
The aiohttp URL builder only accepts strings, ints and floats as query
values and raises a TypeError on a Python bool, so deleting an image
answered a 500. Send true/false like the controller does.
2026-11-06 14:31:26 +08:00
YueGuobin c1e03194d3 api: list Docker image details and delete images
GET /computes/{compute_id}/docker/images now returns the daemon image
id, size and creation date in addition to the tag, so a UI can show
and compare images instead of bare names. The fields are optional to
keep the response compatible with older computes.

Add DELETE /computes/{compute_id}/docker/images/{image} which removes
the image from the compute Docker daemon. The image name is passed as
a path so names with a slash or a tag separator work. A daemon that
refuses because a container uses the image answers a conflict; the
force query parameter removes it anyway.

The compute also drops the controller digest to local id translation
recorded when the image was synced, since it no longer matches
anything on the daemon.
2026-11-06 14:22:22 +08:00
YueGuobin 4c00d223e4 fix: pass the leading slash to the compute load endpoint path
getUrl joins the path directly after /v3/compute, so a path without
the leading slash produces /v3/computedocker/images/load and the
compute answers 404. Match the path shape used by forward() and the
image sync path.
2026-11-06 12:46:21 +08:00
YueGuobin 092202615e api: add controller endpoint to load Docker images on a compute
Add POST /computes/{compute_id}/docker/images/load. It streams an
uploaded docker save tar (plain or gzip) to the compute, which loads
it into its Docker daemon. Clients can now install image packages
such as iol-xe-17-18-02.tar.gz without SSH access to the server.

The body is proxied without buffering (async iterable to chunked
transfer) and runs with no total timeout, like the existing image
sync path between computes.
2026-11-06 10:00:17 +08:00
Jeremy Grossmann 111a4b756c Merge pull request #3061 from yueguobin/fix/remove-ace-route-dump
Remove leftover debug print dumping all routes on ACE creation
2026-11-05 17:26:11 +01:00
Jeremy Grossmann c80bea5279 Merge pull request #3060 from yueguobin/feat/user-privileges-endpoint
Add endpoint returning the current user's privileges
2026-11-05 17:25:29 +01:00
YueGuobin 425d91c085 fix: remove leftover debug print dumping all routes on ACE creation
Creating an ACE printed every route path of the application to stdout.
The debug loop was left behind when the route iteration moved to
_iter_route_paths() in 021991410.
2026-11-05 23:13:42 +08:00
YueGuobin 3ddfe0254d api: add endpoint returning the current user's privileges
Add GET /access/users/me/privileges. It returns the effective privileges
of the logged in user, flattened across all roles assigned to the user
(directly or through group membership). Superadmins get the full
privilege list.

This gives clients a way to render their UI based on permissions. The
server stays the authority for permission checks: a privilege in the
list can still be denied on a specific resource path.
2026-11-05 23:05:28 +08:00
Jeremy Grossmann 94077b4884 Merge pull request #3051 from GNS3/arm64-qemu-support
Enhance aarch64 support with UEFI and improved console options
2026-11-05 14:06:29 +01:00
Jeremy Grossmann 2e5e761c3e Merge pull request #3057 from GNS3/dependabot/pip/3.1/langgraph-gte-1.2.19
Update langgraph requirement from >=1.0.9 to >=1.2.19
2026-11-05 14:04:49 +01:00
Jeremy Grossmann ac54b6d717 Merge branch '3.1' into dependabot/pip/3.1/langgraph-gte-1.2.19 2026-11-05 13:21:28 +01:00
Jeremy Grossmann ddd1f61b6a Merge pull request #3056 from GNS3/dependabot/pip/3.1/python-dotenv-gte-1.2.4
Update python-dotenv requirement from >=1.2.1 to >=1.2.4
2026-11-05 13:20:27 +01:00
Jeremy Grossmann 0a04d084cc Merge pull request #3055 from GNS3/dependabot/pip/3.1/langchain-core-gte-1.7.3
Update langchain-core requirement from >=1.2.16 to >=1.7.3
2026-11-05 13:20:01 +01:00
Jeremy Grossmann 0331cfd2f3 Merge pull request #3054 from GNS3/dependabot/pip/3.1/langchain-openai-gte-1.7.3
Update langchain-openai requirement from >=1.7.2 to >=1.7.3
2026-11-05 13:19:44 +01:00
Jeremy Grossmann d6e70da9ff Merge pull request #3053 from GNS3/dependabot/pip/3.1/langgraph-checkpoint-sqlite-gte-3.1.3
Update langgraph-checkpoint-sqlite requirement from >=3.1.2 to >=3.1.3
2026-11-05 13:19:29 +01:00
Jeremy Grossmann 5943887031 Merge pull request #3058 from GNS3/dependabot/pip/3.1/langchain-xai-gte-1.3.0
Update langchain-xai requirement from >=1.2.2 to >=1.3.0
2026-11-05 13:19:06 +01:00
Jeremy Grossmann b539793281 Merge pull request #3059 from yueguobin/fix/duplicate-project-created-by
fix: attribute duplicated project to the user who duplicated it
2026-11-05 12:49:08 +01:00
dependabot[bot] c327055703 Update langchain-xai requirement from >=1.2.2 to >=1.3.0
Updates the requirements on [langchain-xai](https://github.com/langchain-ai/langchain) to permit the latest version.
- [Release notes](https://github.com/langchain-ai/langchain/releases)
- [Commits](https://github.com/langchain-ai/langchain/compare/langchain-xai==1.2.2...langchain-xai==1.3.0)

---
updated-dependencies:
- dependency-name: langchain-xai
  dependency-version: 1.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-05 11:26:06 +00:00
dependabot[bot] 9429dad9d0 Update langgraph requirement from >=1.0.9 to >=1.2.19
Updates the requirements on [langgraph](https://github.com/langchain-ai/langgraph) to permit the latest version.
- [Release notes](https://github.com/langchain-ai/langgraph/releases)
- [Commits](https://github.com/langchain-ai/langgraph/compare/1.0.9...1.2.19)

---
updated-dependencies:
- dependency-name: langgraph
  dependency-version: 1.2.19
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-05 11:23:29 +00:00
dependabot[bot] 2480fb669e Update python-dotenv requirement from >=1.2.1 to >=1.2.4
Updates the requirements on [python-dotenv](https://github.com/theskumar/python-dotenv) to permit the latest version.
- [Release notes](https://github.com/theskumar/python-dotenv/releases)
- [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.4)

---
updated-dependencies:
- dependency-name: python-dotenv
  dependency-version: 1.2.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-05 11:23:18 +00:00
dependabot[bot] 70155eac83 Update langchain-core requirement from >=1.2.16 to >=1.7.3
Updates the requirements on [langchain-core](https://github.com/langchain-ai/langchain) to permit the latest version.
- [Release notes](https://github.com/langchain-ai/langchain/releases)
- [Commits](https://github.com/langchain-ai/langchain/compare/langchain-core==1.2.16...langchain-core==1.7.3)

---
updated-dependencies:
- dependency-name: langchain-core
  dependency-version: 1.7.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-05 11:23:13 +00:00
dependabot[bot] e7b901de30 Update langchain-openai requirement from >=1.7.2 to >=1.7.3
Updates the requirements on [langchain-openai](https://github.com/langchain-ai/langchain) to permit the latest version.
- [Release notes](https://github.com/langchain-ai/langchain/releases)
- [Commits](https://github.com/langchain-ai/langchain/compare/langchain-openai==1.7.2...langchain-openai==1.7.3)

---
updated-dependencies:
- dependency-name: langchain-openai
  dependency-version: 1.7.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-05 11:22:59 +00:00
dependabot[bot] 6a7c9e4417 Update langgraph-checkpoint-sqlite requirement from >=3.1.2 to >=3.1.3
Updates the requirements on [langgraph-checkpoint-sqlite](https://github.com/langchain-ai/langgraph) to permit the latest version.
- [Release notes](https://github.com/langchain-ai/langgraph/releases)
- [Commits](https://github.com/langchain-ai/langgraph/compare/checkpointsqlite==3.1.2...checkpointsqlite==3.1.3)

---
updated-dependencies:
- dependency-name: langgraph-checkpoint-sqlite
  dependency-version: 3.1.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-05 11:22:40 +00:00
YueGuobin 517b0b0d85 fix: attribute duplicated project to the user who duplicated it
duplicate() kept the original author in created_by. The project list
filters on created_by, so a non-admin user could not see the copy they
just made. Pass the caller through both duplication paths, the same way
import already does since #3017.
2026-11-05 19:22:27 +08:00
Jeremy Grossmann 7230e86c15 Merge pull request #3052 from yueguobin/fix/legacy-project-list-visibility
fix: keep legacy projects visible to non-admin users
2026-11-05 09:49:00 +01:00
YueGuobin 70ef92b55f fix: keep legacy projects visible to non-admin users
Projects created before GNS3 3.1 have no created_by field. The project
list filtered them out for every non-superadmin user, so all old
projects disappeared from the list after an upgrade.

Fall back to the pre-3.1 ACE-only visibility rule when created_by is
not set. Projects owned by another user stay hidden.
2026-11-05 15:42:51 +08:00
Jeremy Grossmann bbb1dc4f80 Merge pull request #3050 from yueguobin/fix/graceful-shutdown-timeout
Bound graceful shutdown and stop notification WebSockets on exit
2026-11-04 06:40:42 +01:00
YueGuobin a56fce4c36 fix: do not monkeypatch attributes on Starlette State in tests
pytest 9.2 reads the old value of a monkeypatched attribute from the
instance __dict__. Starlette State keeps attributes in an internal
dict, so the old value looks unset and the teardown deletes the
attribute for the rest of the test session. The first test that
touches app.state.exiting passes but removes it, and every later
monkeypatch.setattr on it fails with AttributeError.

Use plain assignments instead.
2026-11-04 09:22:15 +08:00
Guobin Yue 8f763c0439 Merge branch '3.1' into fix/graceful-shutdown-timeout 2026-11-04 00:59:22 +08:00
YueGuobin 52c19002f2 fix: bound graceful shutdown and stop notification WebSockets on exit
By default uvicorn waits for open connections forever. A long-lived
streaming response keeps the server waiting after Ctrl+C. One common
case is the MCP Streamable HTTP transport: the client opens a
standalone GET request and the server answers with an SSE stream
(not the old SSE transport). A second Ctrl+C sets force_exit. Uvicorn
then skips the lifespan shutdown, so project and controller cleanup
does not run.

Add timeout_graceful_shutdown. After the timeout uvicorn cancels the
remaining tasks and runs the lifespan shutdown. Also make the four
notification WebSocket handlers stop when app.state.exiting is set.
The HTTP stream handlers already do this.
2026-11-04 00:32:34 +08:00
51 changed files with 2779 additions and 477 deletions

No files matched your search

+9 -9
View File
@@ -8,20 +8,20 @@
# Core AI and Automation Framework
# Note: aiosqlite is in core requirements.txt
langchain>=1.2.10
langchain-core>=1.2.16
langgraph>=1.0.9
langchain-core>=1.7.3
langgraph>=1.2.19
langgraph-checkpoint>=4.0.0
langgraph-checkpoint-sqlite>=3.1.2
langgraph-checkpoint-sqlite>=3.1.3
langgraph-checkpoint-postgres>=3.0.4
# Model Providers
langchain-openai>=1.7.2
langchain-anthropic>=1.3.4
langchain-openai>=1.7.3
langchain-anthropic>=1.7.10
langchain-google-genai>=4.4.2
langchain-aws>=1.9.0
langchain-ollama>=1.1.0
langchain-deepseek>=1.1.1
langchain-xai>=1.2.2
langchain-xai>=1.3.0
# Token Counting
tiktoken>=0.14.0
@@ -30,15 +30,15 @@ tiktoken>=0.14.0
langsmith>=0.14.7
# Network Automation
paramiko>=5.0.0 # install version >= 5.0.0 to address CVE-2026-44405
paramiko>=5.1.0 # install version >= 5.0.0 to address CVE-2026-44405
netmiko>=4.8.0
nornir>=3.6.0
nornir>=3.6.1
nornir-netmiko>=1.0.1
nornir-utils>=0.3.0
nornir-salt>=0.23.4
# Environment & Configuration
python-dotenv>=1.2.1
python-dotenv>=1.2.4
# Authentication
PyJWT>=2.15.1
+1 -1
View File
@@ -1 +1 @@
{"openapi": "3.1.0", "info": {"title": "GNS3 controller API", "description": "This page describes the public controller API for GNS3", "version": "3.0.0"}, "paths": {"/v3/version": {"get": {"tags": ["Controller"], "summary": "Get Version", "description": "Return the server version number.", "operationId": "get_version_v3_version_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}}}}, "post": {"tags": ["Controller"], "summary": "Check Version", "description": "Check if version is the same as the server.", "operationId": "check_version_v3_version_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}}, "409": {"description": "Invalid version", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/v3/reload": {"post": {"tags": ["Controller"], "summary": "Reload", "description": "Reload the controller", "operationId": "reload_v3_reload_post", "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/shutdown": {"post": {"tags": ["Controller"], "summary": "Shutdown", "description": "Shutdown the server", "operationId": "shutdown_v3_shutdown_post", "responses": {"204": {"description": "Successful Response"}, "403": {"description": "Server shutdown not allowed", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/iou_license": {"get": {"tags": ["Controller"], "summary": "Get Iou License", "description": "Return the IOU license settings", "operationId": "get_iou_license_v3_iou_license_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}, "put": {"tags": ["Controller"], "summary": "Update Iou License", "description": "Update the IOU license settings.", "operationId": "update_iou_license_v3_iou_license_put", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/statistics": {"get": {"tags": ["Controller"], "summary": "Statistics", "description": "Return server statistics including compute resources, projects, and nodes.", "operationId": "statistics_v3_statistics_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"additionalProperties": true, "type": "object", "title": "Response Statistics V3 Statistics Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/notifications": {"get": {"tags": ["Controller"], "summary": "Controller Http Notifications", "description": "Receive controller notifications about the controller from HTTP stream.", "operationId": "controller_http_notifications_v3_notifications_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/settings": {"get": {"tags": ["Server settings"], "summary": "Get Server Settings", "description": "Return the server settings.\n\nThe values reflect the running configuration (which may include command\nline overrides). Secret fields are masked.", "operationId": "get_server_settings_v3_settings_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SettingsResponse"}}}}, "401": {"description": "Unauthorized", "cLine truncated
{"openapi": "3.1.0", "info": {"title": "GNS3 controller API", "description": "This page describes the public controller API for GNS3", "version": "3.1.0.dev8"}, "servers": [{"url": "/"}], "paths": {"/v3/version": {"get": {"tags": ["Controller"], "summary": "Get Version", "description": "Return the server version number.", "operationId": "controller_get_version", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}}}}, "post": {"tags": ["Controller"], "summary": "Check Version", "description": "Check if version is the same as the server.", "operationId": "controller_check_version", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Version"}}}}, "409": {"description": "Invalid version", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Request validation error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}}}}, "/v3/reload": {"post": {"tags": ["Controller"], "summary": "Reload", "description": "Reload the controller", "operationId": "controller_reload", "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Request validation error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "401": {"description": "Authentication is required or the credentials are invalid", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "403": {"description": "Not enough privileges to perform this operation", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/shutdown": {"post": {"tags": ["Controller"], "summary": "Shutdown", "description": "Shutdown the server", "operationId": "controller_shutdown", "responses": {"204": {"description": "Successful Response"}, "403": {"description": "Not enough privileges to perform this operation", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "422": {"description": "Request validation error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "401": {"description": "Authentication is required or the credentials are invalid", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/iou_license": {"get": {"tags": ["Controller"], "summary": "Get Iou License", "description": "Return the IOU license settings", "operationId": "controller_get_iou_license", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}}, "422": {"description": "Request validation error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "401": {"description": "Authentication is required or the credentials are invalid", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "403": {"description": "Not enough privileges to perform this operation", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}, "put": {"tags": ["Controller"], "summary": "Update Iou License", "description": "Update the IOU license settings.", "operationId": "controller_update_iou_license", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IOULicense"}}}}, "422": {"description": "Request validation error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "401": {"description": "Authentication is required or the credentials are invalid", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}, "403": {"description": "Not enough privileges to perform this operation", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/v3/statistics": {"get": {"tags": ["Controller"], "summary": "Statistics", "description": "Return server statistics including compute resources, projects, and nodes.", "operationId": "controller_statistics", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ServerStatistics"}}}}, "422": {"description": "Request validation error", "content": {"application/jLine truncated
@@ -67,6 +67,7 @@ compute_api = FastAPI(
)
compute_api.state.controller_host = None
compute_api.state.exiting = False
@compute_api.exception_handler(ComputeError)
+74 -3
View File
@@ -19,16 +19,24 @@ API routes for images.
"""
import os
import time
import urllib.parse
from typing import List, Optional
from fastapi import APIRouter, Body, HTTPException, Query, Request, status
from fastapi import APIRouter, Body, HTTPException, Path, Query, Request, status
from fastapi.responses import FileResponse
from gns3server.compute.docker import Docker
from gns3server.compute.docker.docker_error import DockerError
from gns3server.compute.dynamips import Dynamips
from gns3server.compute.iou import IOU
from gns3server.compute.notification_manager import NotificationManager
from gns3server.compute.qemu import Qemu
from gns3server.utils.images import DOCKER_IMAGE_NAME_PATTERN, validate_docker_image_name
# Daemon progress lines arrive dozens of times per second per layer: emit at
# most one downloading event per second per pull.
PULL_PROGRESS_INTERVAL = 1.0
router = APIRouter()
@@ -44,13 +52,55 @@ async def get_docker_images() -> List[dict]:
@router.post("/docker/images/pull", status_code=status.HTTP_204_NO_CONTENT)
async def pull_docker_image(image: str = Body(..., embed=True, min_length=1, pattern=r"^\S+$")) -> None:
async def pull_docker_image(
image: str = Body(..., embed=True, min_length=1, pattern=r"^\S+$"),
progress_id: Optional[str] = Query(
None, pattern=r"^\S+$", description="Client supplied id to correlate progress notifications"
),
) -> None:
"""
Pull or update a Docker image.
The pull progress is reported as docker.image.pull notifications: a
started event, throttled downloading events and a terminal completed or
failed event. The optional progress_id is echoed in every event so a
client can correlate them with its own request.
"""
docker_manager = Docker.instance()
await docker_manager.pull_image(image, force=True)
def emit(state: str, message: Optional[str] = None, progress: Optional[dict] = None) -> None:
event = {"image": image, "state": state}
if message is not None:
event["message"] = message
if progress is not None:
event["layers_total"] = progress["layers_total"]
event["layers_done"] = progress["layers_done"]
if progress_id is not None:
event["progress_id"] = progress_id
NotificationManager.instance().emit("docker.image.pull", event)
last_emit = 0.0
def progress_callback(progress: dict) -> None:
nonlocal last_emit
# the started event comes from the pull itself, once it owns the lock,
# and is never throttled
if progress.get("state") == "started":
emit("started", progress["message"])
return
now = time.monotonic()
if now - last_emit < PULL_PROGRESS_INTERVAL:
return
last_emit = now
emit("downloading", progress["message"], progress)
try:
await docker_manager.pull_image(image, progress_callback=progress_callback, force=True)
except DockerError as e:
emit("failed", str(e))
raise
emit("completed", f"Success pulling image {image}")
@router.post("/docker/images/load", status_code=status.HTTP_204_NO_CONTENT)
@@ -73,6 +123,27 @@ async def load_docker_image(
await docker_manager.load_image(request.stream(), image=image, digest=digest)
@router.delete("/docker/images/{image:path}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_docker_image(
image: str = Path(..., pattern=DOCKER_IMAGE_NAME_PATTERN),
force: bool = Query(False, description="Remove the image even if a container uses it"),
) -> None:
"""
Delete a Docker image from the Docker daemon.
The daemon refuses to delete an image used by a container (even a stopped
one) and answers a conflict, unless force is set.
"""
image = urllib.parse.unquote(image)
try:
validate_docker_image_name(image)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e)) from e
docker_manager = Docker.instance()
await docker_manager.delete_image(image, force=force)
@router.get("/dynamips/images")
async def get_dynamips_images() -> List[dict]:
"""
@@ -44,7 +44,7 @@ async def project_ws_notifications(websocket: Union[None, WebSocket] = Depends(w
log.info(f"New client {client} has connected to compute WebSocket")
try:
with NotificationManager.instance().queue() as queue:
while True:
while not websocket.app.state.exiting:
notification = await queue.get_json(5)
await websocket.send_text(notification)
except (ConnectionClosed, WebSocketDisconnect):
-3
View File
@@ -203,9 +203,6 @@ async def create_ace(
Required privilege: ACE.Allocate
"""
for route_path in _iter_route_paths(request.app.routes, include_mounted_routes=True):
print(route_path)
for route_path in _iter_route_paths(request.app.routes, include_mounted_routes=True):
# remove the prefix (e.g. "/v3") from the route path
normalized_path = re.sub(r"^/v[0-9]", "", route_path)
+104 -3
View File
@@ -21,7 +21,7 @@ API routes for computes.
from typing import Any, List, Optional, Union
from uuid import UUID
from fastapi import APIRouter, Body, Depends, Query, status
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request, status
import gns3server.db.models as models
from gns3server import schemas
@@ -29,7 +29,13 @@ from gns3server.controller import Controller
from gns3server.controller.controller_error import ControllerBadRequestError, ControllerError
from gns3server.db.repositories.computes import ComputesRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.db.repositories.templates import TemplatesRepository
from gns3server.services.computes import ComputesService
from gns3server.utils.images import (
DOCKER_IMAGE_NAME_PATTERN,
canonical_docker_image_name,
validate_docker_image_name,
)
from .dependencies.database import get_repository
from .dependencies.rbac import has_privilege
@@ -215,16 +221,111 @@ async def docker_get_images(compute_id: Union[str, UUID]) -> List[schemas.Comput
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+$")
compute_id: Union[str, UUID],
image: str = Body(..., embed=True, min_length=1, pattern=r"^\S+$"),
progress_id: Optional[str] = Query(
None, pattern=r"^\S+$", description="Client supplied id to correlate progress notifications"
),
) -> None:
"""
Pull or update a Docker image on a compute.
The pull progress is reported on the controller notification stream as
docker.image.pull events (started, downloading, completed or failed). The
optional progress_id is echoed in every event so a client can correlate
them with its own request.
Required privilege: Compute.Modify
"""
compute = Controller.instance().get_compute(str(compute_id))
await compute.forward("POST", "docker", "images/pull", data={"image": image})
params = {"progress_id": progress_id} if progress_id is not None else None
await compute.forward("POST", "docker", "images/pull", data={"image": image}, params=params)
@router.post(
"/{compute_id}/docker/images/load",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("Compute.Modify"))],
)
async def docker_load_image(compute_id: Union[str, UUID], request: Request) -> None:
"""
Load a Docker image on a compute from a docker save tar stream.
The tar archive (plain or gzip compressed) is streamed to the compute
and loaded into its Docker daemon, without buffering the whole file
in the controller memory.
Required privilege: Compute.Modify
"""
compute = Controller.instance().get_compute(str(compute_id))
await compute.http_query("POST", "/docker/images/load", data=request.stream(), timeout=None)
@router.delete(
"/{compute_id}/docker/images/{image:path}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("Compute.Modify"))],
)
async def docker_delete_image(
compute_id: Union[str, UUID],
image: str = Path(..., pattern=DOCKER_IMAGE_NAME_PATTERN),
force: bool = Query(False, description="Remove the image even if a container uses it"),
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
) -> None:
"""
Delete a Docker image on a compute.
The deletion is refused while templates reference the image, unless force
is set. The compute also refuses an image used by a container (even a
stopped one) and answers a conflict naming the GNS3 nodes and projects
using it.
After a successful deletion a docker.image.deleted notification is
emitted on the controller notification stream, with the templates that
referenced the image, so clients can refresh their image lists.
Required privilege: Compute.Modify
"""
try:
validate_docker_image_name(image)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e)) from e
canonical_image = canonical_docker_image_name(image)
referencing_templates = [
str(template.name)
for template in await templates_repo.get_templates()
if isinstance(template, models.DockerTemplate)
and template.image
and canonical_docker_image_name(str(template.image)) == canonical_image
]
if referencing_templates and not force:
# refuse before touching the compute: the client can prompt the user
# and repeat with force once the user confirms
raise ControllerError(
f"Image '{image}' is referenced by template(s): {', '.join(referencing_templates)}. "
f"Delete the templates first or repeat with force=true.",
code="image_referenced_by_templates",
details={"referenced_by_templates": referencing_templates},
)
compute = Controller.instance().get_compute(str(compute_id))
await compute.http_query("DELETE", f"/docker/images/{image}", params={"force": str(force).lower()})
# tell the clients the image changed on this compute: they can refresh
# their image lists, and the templates that referenced the image can no
# longer start their nodes
Controller.instance().notification.controller_emit(
"docker.image.deleted",
{
"compute_id": str(compute_id),
"image": image,
"referenced_by_templates": referencing_templates,
},
)
@router.get("/{compute_id}/virtualbox/vms", response_model=List[schemas.ComputeVirtualBoxVM])
@@ -308,7 +308,7 @@ async def controller_ws_notifications(
log.info(f"New client {client} has connected to controller WebSocket")
try:
with Controller.instance().notification.controller_queue() as queue:
while True:
while not websocket.app.state.exiting:
notification = await queue.get_json(5)
await websocket.send_text(notification)
except (ConnectionClosed, WebSocketDisconnect):
+1 -1
View File
@@ -140,7 +140,7 @@ async def delete_user_group(
success = await users_repo.delete_user_group(user_group_id)
if not success:
raise ControllerError(f"User group '{user_group_id}' could not be deleted")
await rbac_repo.delete_all_ace_starting_with_path(f"/groups/{user_group_id}")
await rbac_repo.delete_all_ace_starting_with_path(f"/access/groups/{user_group_id}")
@router.get(
+50 -4
View File
@@ -126,10 +126,12 @@ async def get_projects(
# Step 2: Filter direct ACE projects by created_by
# Direct project sharing is only available through resource pools
# Projects created before the created_by field existed (GNS3 < 3.1) have no
# owner: fall back to the ACE-only visibility rule instead of hiding them.
for p in controller.projects.values():
if name is not None and p.name != name:
continue
if p.id in direct_ace_ids and p.created_by == current_user.username:
if p.id in direct_ace_ids and (p.created_by is None or p.created_by == current_user.username):
if p.id not in seen_project_ids:
projects.append(p.asdict())
seen_project_ids.add(p.id)
@@ -189,6 +191,46 @@ def get_project(response: Response, project: Project = Depends(dep_project)) ->
return project_dict
@router.get(
"/{project_id}/me/privileges",
response_model=schemas.ProjectPrivileges,
responses=GET_RESPONSES,
dependencies=[Depends(has_privilege("Project.Audit"))],
)
async def get_logged_in_user_privileges_on_project(
current_user: schemas.User = Depends(get_current_active_user),
project: Project = Depends(dep_project),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
) -> schemas.ProjectPrivileges:
"""
Get the effective privileges of the current user on this project.
Unlike /access/users/me/privileges (flattened across all resource paths),
each privilege is evaluated on the project's resource path, so the result
reflects ACEs scoped to this project and resource pools containing it.
This is meant for rendering purposes (e.g. showing or hiding buttons in
the project view): the server remains the authority for permission checks.
Required privilege: Project.Audit
Example: curl -X GET http://host:port/v3/projects/{project_id}/me/privileges -H "Authorization: Bearer <token>"
"""
if current_user.is_superadmin:
privilege_names = sorted(privilege.name for privilege in await rbac_repo.get_privileges())
return schemas.ProjectPrivileges(is_superadmin=True, privileges=privilege_names)
path = f"/projects/{project.id}"
# the flat view is the superset: only privileges granted somewhere can be
# granted on this path, so only those need to be evaluated against it
candidates = await rbac_repo.get_user_privileges(current_user.user_id)
project_privileges = []
for name in candidates:
if await rbac_repo.check_user_has_privilege(current_user.user_id, path, name):
project_privileges.append(name)
return schemas.ProjectPrivileges(is_superadmin=False, privileges=sorted(project_privileges))
@router.put(
"/{project_id}",
response_model=schemas.Project,
@@ -659,7 +701,7 @@ async def project_ws_notifications(
log.info(f"New client has connected to the notification stream for project ID '{project.id}' (WebSocket method)")
try:
with controller.notification.project_queue(project.id) as queue:
while True:
while not websocket.app.state.exiting:
notification = await queue.get_json(5)
await websocket.send_text(notification)
except (ConnectionClosed, WebSocketDisconnect):
@@ -701,7 +743,7 @@ async def project_marker_ws_notifications(
)
try:
with controller.notification.project_marker_queue(project.id) as queue:
while True:
while not websocket.app.state.exiting:
notification = await queue.get_json(5)
await websocket.send_text(notification)
except (ConnectionClosed, WebSocketDisconnect):
@@ -884,7 +926,11 @@ async def duplicate_project(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
reset_mac_addresses = project_data.reset_mac_addresses
new_project = await project.duplicate(name=project_data.name, reset_mac_addresses=reset_mac_addresses)
new_project = await project.duplicate(
name=project_data.name,
reset_mac_addresses=reset_mac_addresses,
user=current_user.username,
)
# Add the new project in the same resource pools if the duplicated project belongs to any
if pool_memberships:
+1 -1
View File
@@ -135,7 +135,7 @@ async def delete_role(
success = await rbac_repo.delete_role(role_id)
if not success:
raise ControllerError(f"Role '{role_id}' could not be deleted")
await rbac_repo.delete_all_ace_starting_with_path(f"/roles/{role_id}")
await rbac_repo.delete_all_ace_starting_with_path(f"/access/roles/{role_id}")
@router.get(
+26 -1
View File
@@ -165,6 +165,31 @@ async def update_logged_in_user(
return await users_repo.update_user(current_user.user_id, user_update)
@router.get("/me/privileges", response_model=schemas.UserPrivileges)
async def get_logged_in_user_privileges(
current_user: schemas.User = Depends(get_current_active_user),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
) -> schemas.UserPrivileges:
"""
Get the effective privileges of the current active user.
The privileges are the union of everything granted by the roles assigned
to the user (directly or through group membership), flattened across all
resource paths. This is meant for rendering purposes (e.g. showing or
hiding parts of the UI): the server remains the authority for permission
checks and can still deny a privilege on a specific resource path.
Example: curl -X GET http://host:port/v3/access/users/me/privileges -H "Authorization: Bearer <token>"
"""
if current_user.is_superadmin:
privileges = sorted(privilege.name for privilege in await rbac_repo.get_privileges())
return schemas.UserPrivileges(is_superadmin=True, privileges=privileges)
privilege_names = await rbac_repo.get_user_privileges(current_user.user_id)
return schemas.UserPrivileges(is_superadmin=False, privileges=sorted(privilege_names))
@router.get("", response_model=List[schemas.User], dependencies=[Depends(has_privilege("User.Audit"))])
async def get_users(users_repo: UsersRepository = Depends(get_repository(UsersRepository))) -> List[models.User]:
"""
@@ -265,7 +290,7 @@ async def delete_user(
success = await users_repo.delete_user(user_id)
if not success:
raise ControllerError(f"User '{user_id}' could not be deleted")
await rbac_repo.delete_all_ace_starting_with_path(f"/users/{user_id}")
await rbac_repo.delete_all_ace_starting_with_path(f"/access/users/{user_id}")
@router.get(
+2
View File
@@ -134,6 +134,8 @@ unicorn_exit_handler = UvicornServer.handle_exit
def handle_exit(*args, **kwargs):
app.state.exiting = True
# The compute API is a mounted sub-app with its own State object.
compute_api.state.exiting = True
unicorn_exit_handler(*args, **kwargs)
+5 -179
View File
@@ -35,12 +35,12 @@ from gns3server.compute.docker.docker_error import (
DockerHttp404Error,
DockerHttp409Error,
)
from gns3server.compute.docker.docker_images import DockerImagesMixin
from gns3server.compute.docker.docker_vm import DockerVM
from gns3server.compute.docker.iol_docker_vm import IOLDockerVM
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
from gns3server.config import Config
from gns3server.utils import parse_version
from gns3server.utils.asyncio import locking
log = logging.getLogger(__name__)
@@ -49,10 +49,9 @@ log = logging.getLogger(__name__)
DOCKER_MINIMUM_API_VERSION = "1.40"
DOCKER_MINIMUM_VERSION = "19.03.8"
DOCKER_PREFERRED_API_VERSION = "1.44"
CHUNK_SIZE = 1024 * 8 # 8KB
class Docker(BaseManager):
class Docker(DockerImagesMixin, BaseManager):
_NODE_CLASS = DockerVM
def __init__(self):
@@ -291,7 +290,8 @@ class Docker(BaseManager):
:param path: Endpoint in API
:param data: Dictionary with the body. Will be transformed to a JSON
:param params: Parameters added as a query arg
:param timeout: Timeout
:param timeout: Total timeout in seconds, an aiohttp ClientTimeout object
for a finer policy (e.g. an idle socket read timeout), or None for no timeout
:returns: HTTP response
"""
@@ -302,7 +302,7 @@ class Docker(BaseManager):
# not a dict (e.g. a Docker image tar stream): let aiohttp stream the raw body
headers = {"content-type": "application/x-tar"}
if timeout is None:
timeout = 60 * 60 * 24 * 31 # One month timeout
timeout = aiohttp.ClientTimeout(total=None)
if path == "version":
url = "http://docker/" + path
@@ -354,177 +354,3 @@ class Docker(BaseManager):
url = "http://docker/v" + self._api_version + "/" + path
connection = await self._session.ws_connect(url, origin="http://docker", autoping=True)
return connection
@locking
async def pull_image(self, image, progress_callback=None, force=False):
"""
Pulls an image from the Docker repository
:params image: Image name
:params progress_callback: A function that receive a log message about image download progress
:params force: Pull the image even if it is already available locally
"""
if not force:
try:
await self.query("GET", f"images/{image}/json")
return # We already have the image skip the download
except DockerHttp404Error:
pass
if progress_callback:
progress_callback(f"Pulling '{image}' from Docker repository")
try:
response = await self.http_query("POST", "images/create", params={"fromImage": image}, timeout=None)
except DockerError as e:
raise DockerError(
f"Could not pull the '{image}' image from Docker repository, "
f"please check your Internet connection (original error: {e})"
)
# The pull api will stream status via an HTTP JSON stream
content = ""
try:
while True:
try:
chunk = await response.content.read(CHUNK_SIZE)
except aiohttp.ServerDisconnectedError as e:
raise DockerError(
f"Disconnected while pulling Docker image '{image}' from Docker repository"
) from e
except TimeoutError as e:
raise DockerError(f"Timeout while pulling Docker image '{image}' from Docker repository") from e
if not chunk:
break
content += chunk.decode("utf-8")
try:
while True:
content = content.lstrip(" \r\n\t")
answer, index = json.JSONDecoder().raw_decode(content)
if not isinstance(answer, dict):
raise DockerError(f"Invalid response while pulling Docker image '{image}'")
error_detail = answer.get("errorDetail")
error = answer.get("error")
if not error and isinstance(error_detail, dict):
error = error_detail.get("message")
if error:
raise DockerError(error)
if "progress" in answer and progress_callback:
progress_callback("Pulling image {}:{}: {}".format(image, answer["id"], answer["progress"]))
content = content[index:]
except ValueError: # Partial JSON
pass
if content.strip():
raise DockerError(f"Invalid response while pulling Docker image '{image}'")
finally:
response.close()
if progress_callback:
progress_callback(f"Success pulling image {image}")
@locking
async def load_image(self, stream, image=None, digest=None, progress_callback=None):
"""
Load a Docker image into the Docker daemon from a docker save tar stream
:param stream: An async iterable of bytes (the tar produced by docker save)
:param image: Tag of the image in the archive. Together with digest, the
(controller digest, local id) pair is remembered so that node creation
can accept the digest the controller pinned for this image
:param digest: Image id as reported by the Docker daemon on the controller
host (the value the controller pins in the node create payload)
:param progress_callback: A function that receive a log message about image load progress
"""
if progress_callback:
progress_callback("Loading Docker image from stream")
response = await self.http_query("POST", "images/load", data=stream, timeout=None)
# The load api will stream status via an HTTP JSON stream
content = ""
try:
while True:
try:
chunk = await response.content.read(CHUNK_SIZE)
except aiohttp.ServerDisconnectedError as e:
raise DockerError("Disconnected while loading Docker image") from e
except TimeoutError as e:
raise DockerError("Timeout while loading Docker image") from e
if not chunk:
break
content += chunk.decode("utf-8", errors="ignore")
try:
while True:
content = content.lstrip(" \r\n\t")
answer, index = json.JSONDecoder().raw_decode(content)
if not isinstance(answer, dict):
raise DockerError("Invalid response while loading Docker image")
error_detail = answer.get("errorDetail")
error = answer.get("error")
if not error and isinstance(error_detail, dict):
error = error_detail.get("message")
if error:
raise DockerError(error)
if "stream" in answer and progress_callback:
progress_callback(answer["stream"].rstrip())
content = content[index:]
except ValueError: # Partial JSON
pass
if content.strip():
raise DockerError("Invalid response while loading Docker image")
finally:
response.close()
if progress_callback:
progress_callback("Docker image loaded")
if image and digest:
await self._remember_image_id_translation(image, digest)
async def _remember_image_id_translation(self, image, controller_digest):
"""
Remember how the local daemon identifies an image that was just loaded
from the controller.
Docker ids are not comparable across image stores: for the same content
a classic store reports the config digest, while a containerd store (or
an image loaded from an OCI archive) reports the manifest digest. The
controller pins the id of its own daemon, so recording the pair lets
DockerVM accept that pin for the image it actually received instead of
asking for another full sync on every node creation.
"""
try:
image_infos = await self.query("GET", f"images/{image}/json")
except DockerError as e:
log.warning(f"Image '{image}' has been loaded but cannot be inspected: {e}")
return
local_id = image_infos.get("Id")
if local_id:
self._image_id_translations[image] = (controller_digest, local_id)
def image_id_translation(self, image):
"""
:returns: the (controller digest, local id) pair recorded the last time
this image was loaded from the controller, None otherwise
"""
return self._image_id_translations.get(image)
async def list_images(self):
"""
Gets Docker image list.
:returns: list of dicts
:rtype: list
"""
images = []
for image in await self.query("GET", "images/json", params={"all": 0}):
if image["RepoTags"]:
for tag in image["RepoTags"]:
if tag != "<none>:<none>":
images.append({"image": tag})
return sorted(images, key=lambda i: i["image"])
+395
View File
@@ -0,0 +1,395 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the Free Software Foundation 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/>.
"""
The image-management half of the Docker manager: pull, load, list and
delete images, plus the translation between the id the controller pins and
the id this daemon reports for the same content. Consumed by the Docker
manager as a mixin, so __init__.py stays focused on the daemon connection,
the HTTP layer and the node factory.
"""
import json
import logging
import re
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
import aiohttp
from gns3server.compute.compute_error import ComputeError
from gns3server.compute.docker.docker_error import (
DockerError,
DockerHttp404Error,
DockerHttp409Error,
)
from gns3server.compute.project_manager import ProjectManager
from gns3server.utils.asyncio import locking
log = logging.getLogger(__name__)
CHUNK_SIZE = 1024 * 8 # 8KB
# A pull streams progress from the daemon for its whole duration: when no data
# arrives for this many seconds the registry is unreachable and the pull would
# otherwise hang forever. This is an idle timeout, not a total time limit.
PULL_IDLE_TIMEOUT = 60
# Docker names GNS3 node containers "GNS3.{node name}.{project_id}", so a
# container name can be translated back into the node and project that block
# an image deletion. The node name may itself contain dots.
GNS3_CONTAINER_NAME_RE = re.compile(
r"^GNS3\.(?P<node>.+)\.(?P<project_id>[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12})$"
)
# The containers blocking an image deletion, as named by the daemon, e.g.
# 'container 6d59f00236ff is using its referenced image d529dd0c6e55'
CONFLICTING_CONTAINER_RE = re.compile(r"container ([0-9a-f]{12,64})\b")
# Images built for reproducibility, such as the Ubuntu images assembled with
# rockcraft, carry no created timestamp in their config and Docker reports 0
# (displayed as 1970-01-01). Their build tool writes the real time in this
# OCI label instead.
OCI_CREATED_LABEL = "org.opencontainers.image.created"
def image_created_timestamp(image: dict) -> int:
"""
Build time of an image as a Unix timestamp.
The daemon timestamp wins when it is set. When it is zero, fall back to
the OCI created label, if present.
:param image: an image summary as returned by the daemon images API
"""
created = image.get("Created") or 0
if created > 0:
return created
label = (image.get("Labels") or {}).get(OCI_CREATED_LABEL, "")
if not label:
return created
try:
built = datetime.fromisoformat(label)
except ValueError:
log.warning(f"Ignoring invalid {OCI_CREATED_LABEL} label: {label!r}")
return created
if built.tzinfo is None:
built = built.replace(tzinfo=UTC)
return int(built.timestamp())
class DockerImagesMixin:
"""
Image management for the Docker manager.
The host manager (Docker) provides the daemon connection: query(),
http_query() and the _image_id_translations dict filled in its __init__.
"""
if TYPE_CHECKING:
_image_id_translations: dict[str, tuple[str, str]]
def query(self, method: str, path: str, *args: Any, **kwargs: Any) -> Any: ...
def http_query(self, method: str, path: str, *args: Any, **kwargs: Any) -> Any: ...
@locking
async def pull_image(self, image, progress_callback=None, force=False):
"""
Pulls an image from the Docker repository
:params image: Image name
:params progress_callback: A function that receives a dict with the
overall progress of the pull: layer counts and the latest daemon
message. The first call carries state="started" and happens once
the pull owns the compute pull lock, so a caller queued behind
another pull only sees it when the pull really begins.
:params force: Pull the image even if it is already available locally
"""
layers_total = 0
layers_done = 0
def report(message, state=None):
if progress_callback:
progress = {"layers_total": layers_total, "layers_done": layers_done, "message": message}
if state is not None:
progress["state"] = state
progress_callback(progress)
report(f"Pulling '{image}' from Docker repository", state="started")
if not force:
try:
await self.query("GET", f"images/{image}/json")
return # We already have the image skip the download
except DockerHttp404Error:
pass
params = {"fromImage": image}
if "@" not in image:
# Without a tag or digest the daemon pulls every tag of the
# repository. Only a ':' after the last '/' is a tag separator:
# before it, it is a registry port (e.g. registry:5000/repo).
if ":" not in image.rpartition("/")[2]:
params["tag"] = "latest"
try:
# The daemon streams progress lines for the whole pull: an idle
# socket read for a minute means the registry is unreachable and
# the pull would otherwise hang forever.
response = await self.http_query(
"POST",
"images/create",
params=params,
timeout=aiohttp.ClientTimeout(total=None, sock_read=PULL_IDLE_TIMEOUT),
)
except DockerHttp404Error as e:
raise DockerError(
f"Could not pull the '{image}' image: it was not found in the registry. "
f"Check the image name or run 'docker login' on the compute host "
f"(original error: {e})"
)
except DockerError as e:
raise DockerError(
f"Could not pull the '{image}' image from Docker repository. "
f"Check the image name, the registry authentication and your Internet connection "
f"(original error: {e})"
)
# The pull api will stream status via an HTTP JSON stream
content = ""
try:
while True:
try:
chunk = await response.content.read(CHUNK_SIZE)
except aiohttp.ServerDisconnectedError as e:
raise DockerError(
f"Disconnected while pulling Docker image '{image}' from Docker repository"
) from e
except TimeoutError as e:
raise DockerError(
f"Timeout while pulling Docker image '{image}' from Docker repository: "
f"no data received for {PULL_IDLE_TIMEOUT} seconds, "
f"please check your Internet connection or Docker registry mirror"
) from e
if not chunk:
break
content += chunk.decode("utf-8")
try:
while True:
content = content.lstrip(" \r\n\t")
answer, index = json.JSONDecoder().raw_decode(content)
if not isinstance(answer, dict):
raise DockerError(f"Invalid response while pulling Docker image '{image}'")
error_detail = answer.get("errorDetail")
error = answer.get("error")
if not error and isinstance(error_detail, dict):
error = error_detail.get("message")
if error:
raise DockerError(error)
status = answer.get("status", "")
if status == "Pulling fs layer":
layers_total += 1
elif status in ("Pull complete", "Already exists"):
layers_done += 1
message = answer.get("progress") or status
if message:
report(message)
content = content[index:]
except ValueError: # Partial JSON
pass
if content.strip():
raise DockerError(f"Invalid response while pulling Docker image '{image}'")
finally:
response.close()
@locking
async def load_image(self, stream, image=None, digest=None, progress_callback=None):
"""
Load a Docker image into the Docker daemon from a docker save tar stream
:param stream: An async iterable of bytes (the tar produced by docker save)
:param image: Tag of the image in the archive. Together with digest, the
(controller digest, local id) pair is remembered so that node creation
can accept the digest the controller pinned for this image
:param digest: Image id as reported by the Docker daemon on the controller
host (the value the controller pins in the node create payload)
:param progress_callback: A function that receive a log message about image load progress
"""
if progress_callback:
progress_callback("Loading Docker image from stream")
response = await self.http_query("POST", "images/load", data=stream, timeout=None)
# The load api will stream status via an HTTP JSON stream
content = ""
try:
while True:
try:
chunk = await response.content.read(CHUNK_SIZE)
except aiohttp.ServerDisconnectedError as e:
raise DockerError("Disconnected while loading Docker image") from e
except TimeoutError as e:
raise DockerError("Timeout while loading Docker image") from e
if not chunk:
break
content += chunk.decode("utf-8", errors="ignore")
try:
while True:
content = content.lstrip(" \r\n\t")
answer, index = json.JSONDecoder().raw_decode(content)
if not isinstance(answer, dict):
raise DockerError("Invalid response while loading Docker image")
error_detail = answer.get("errorDetail")
error = answer.get("error")
if not error and isinstance(error_detail, dict):
error = error_detail.get("message")
if error:
raise DockerError(error)
if "stream" in answer and progress_callback:
progress_callback(answer["stream"].rstrip())
content = content[index:]
except ValueError: # Partial JSON
pass
if content.strip():
raise DockerError("Invalid response while loading Docker image")
finally:
response.close()
if progress_callback:
progress_callback("Docker image loaded")
if image and digest:
await self._remember_image_id_translation(image, digest)
async def _remember_image_id_translation(self, image, controller_digest):
"""
Remember how the local daemon identifies an image that was just loaded
from the controller.
Docker ids are not comparable across image stores: for the same content
a classic store reports the config digest, while a containerd store (or
an image loaded from an OCI archive) reports the manifest digest. The
controller pins the id of its own daemon, so recording the pair lets
DockerVM accept that pin for the image it actually received instead of
asking for another full sync on every node creation.
"""
try:
image_infos = await self.query("GET", f"images/{image}/json")
except DockerError as e:
log.warning(f"Image '{image}' has been loaded but cannot be inspected: {e}")
return
local_id = image_infos.get("Id")
if local_id:
self._image_id_translations[image] = (controller_digest, local_id)
def image_id_translation(self, image):
"""
:returns: the (controller digest, local id) pair recorded the last time
this image was loaded from the controller, None otherwise
"""
return self._image_id_translations.get(image)
async def list_images(self):
"""
Gets Docker image list.
:returns: list of dicts
:rtype: list
"""
images = []
for image in await self.query("GET", "images/json", params={"all": 0}):
if image["RepoTags"]:
for tag in image["RepoTags"]:
if tag != "<none>:<none>":
images.append(
{
"image": tag,
"id": image.get("Id"),
"size": image.get("Size"),
"created": image_created_timestamp(image),
}
)
return sorted(images, key=lambda i: i["image"])
async def delete_image(self, image, force=False):
"""
Delete a Docker image from the Docker daemon.
When the daemon refuses because containers use the image, the error
names the GNS3 nodes and projects behind them instead of raw
container ids.
:param image: Image name or id
:param force: Remove the image even if a container uses it
"""
try:
await self.query("DELETE", f"images/{image}", params={"force": str(force).lower()})
except DockerHttp409Error as e:
blockers = await self._describe_containers_using_image(str(e))
if blockers:
raise DockerHttp409Error(
f"Cannot delete image '{image}': it is still used by {blockers}. "
f"Delete the node or container first, or repeat with force=true."
) from e
raise
# the image id translation recorded when this image was synced from the
# controller no longer matches anything on this daemon
self._image_id_translations.pop(image, None)
async def _describe_containers_using_image(self, daemon_error: str) -> str:
"""
Translate the container ids named in a daemon image deletion conflict
into node, project or container descriptions.
:param daemon_error: the raw daemon conflict message
:returns: a comma separated description, empty when the message names
no container
"""
descriptions = []
for container_id in CONFLICTING_CONTAINER_RE.findall(daemon_error):
try:
info = await self.query("GET", f"containers/{container_id}/json")
except DockerError:
info = None
if not info:
# the container cannot be inspected (or already vanished)
descriptions.append(f"container '{container_id}'")
continue
name = info.get("Name", container_id).lstrip("/")
state = "running" if info.get("State", {}).get("Running") else "stopped"
match = GNS3_CONTAINER_NAME_RE.match(name)
if match:
node = match.group("node")
try:
project = ProjectManager.instance().get_project(match.group("project_id"))
except ComputeError:
# the project is closed on this compute: a leftover container
descriptions.append(f"leftover container of node '{node}' ({state}, its project is closed)")
else:
descriptions.append(f"node '{node}' in project '{project.name}' ({state})")
else:
# a container created outside GNS3
descriptions.append(f"container '{name}' ({state})")
return ", ".join(descriptions)
+2 -2
View File
@@ -733,13 +733,13 @@ class Compute:
async def delete(self, path, **kwargs):
return await self.http_query("DELETE", path, **kwargs)
async def forward(self, method, type, path, data=None):
async def forward(self, method, type, path, data=None, params=None):
"""
Forward a call to the emulator on compute
"""
try:
action = f"/{type}/{path}"
res = await self.http_query(method, action, data=data, timeout=None)
res = await self.http_query(method, action, data=data, timeout=None, params=params)
except aiohttp.ServerDisconnectedError:
raise ControllerError(f"Connection lost to {self._id} during {method} {action}")
return res.json
+6
View File
@@ -126,6 +126,12 @@ class Notification:
elif action == "ping":
event["compute_id"] = compute_id
self.project_emit(action, event)
elif action.startswith("docker.image."):
# Image operations are scoped to a compute, not to a project, and
# clients that are not in a project still need them: emit on the
# controller stream, like compute.updated.
event["compute_id"] = compute_id
self.controller_emit(action, event)
elif action.startswith("marker."):
self.marker_emit(action, event, project_id)
else:
+9 -3
View File
@@ -2158,7 +2158,7 @@ class Project:
while self._loading:
await asyncio.sleep(0.5)
async def duplicate(self, name=None, reset_mac_addresses=True):
async def duplicate(self, name=None, reset_mac_addresses=True, user=None):
"""
Duplicate a project
@@ -2169,6 +2169,7 @@ class Project:
:param name: Name of the new project. A new one will be generated in case of conflicts
:param reset_mac_addresses: Reset MAC addresses for the new project
:param user: The user duplicating the project. The new project is owned by this user
"""
# If the project was not open we open it temporary
@@ -2180,7 +2181,7 @@ class Project:
assert self._status != "closed"
try:
proj = await self._fast_duplication(name, reset_mac_addresses)
proj = await self._fast_duplication(name, reset_mac_addresses=reset_mac_addresses, user=user)
if proj:
if previous_status == "closed":
await self.close()
@@ -2222,6 +2223,7 @@ class Project:
name=name,
reset_mac_addresses=reset_mac_addresses,
keep_compute_ids=True,
user=user,
)
log.info(f"Project '{project.name}' duplicated in {time.time() - begin:.4f} seconds")
@@ -2233,7 +2235,7 @@ class Project:
return project
async def _fast_duplication(self, name=None, location=None, reset_mac_addresses=True):
async def _fast_duplication(self, name=None, location=None, reset_mac_addresses=True, user=None):
"""
Fast duplication of a project.
@@ -2242,6 +2244,7 @@ class Project:
:param name: Name of the new project. A new one will be generated in case of conflicts
:param location: Parent directory of the new project
:param reset_mac_addresses: Reset MAC addresses for the duplicated project
:param user: The user duplicating the project. The new project is owned by this user
"""
# We don't duplicate a running project
@@ -2280,6 +2283,9 @@ class Project:
topology["auto_open"] = False
topology["auto_close"] = False
if user:
topology["created_by"] = user
# regenerate IDs for the duplicated project
regenerate_topology_ids(topology, new_project_path, reset_mac_addresses)
+4 -1
View File
@@ -27,7 +27,7 @@ from gns3server.compute.marker.marker_manager import MarkerManager
from gns3server.compute.port_manager import PortManager
from gns3server.config import Config
from gns3server.controller import Controller
from gns3server.db.tasks import connect_to_db, disconnect_from_db, get_computes
from gns3server.db.tasks import connect_to_db, disconnect_from_db, get_computes, load_rbac_state
from gns3server.services.image_reconciliation import get_image_reconciliation_service
from gns3server.utils.http_client import HTTPClient
@@ -74,6 +74,9 @@ async def startup(app: FastAPI) -> None:
# retrieve the computes from the database
computes = await get_computes(app)
# load the in-memory RBAC snapshot before serving requests
await load_rbac_state(app)
await Controller.instance().start(computes)
get_image_reconciliation_service(app).start_background()
+399
View File
@@ -0,0 +1,399 @@
#
# 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/>.
#
"""
In-memory snapshot of the RBAC state.
The controller keeps its whole runtime state (projects, computes,
notifications) in process memory: a second controller instance is not a
supported setup. This module applies the same idea to the RBAC tables.
All roles, ACEs, group memberships and resource pool memberships are
loaded once into a process-wide snapshot, permission checks then run
against the snapshot instead of querying the database on every request.
The database stays the source of truth. Every repository method that
writes an RBAC table marks the snapshot as dirty, and the next read
rebuilds it from the database. Rebuilding is a handful of small SELECTs
and only happens after (human frequency) administrative writes, so the
checks keep their exact semantics while dropping from several queries
per request (including a full resource scan) to an in-memory lookup.
The evaluation methods replicate the semantics of the previous
database-backed implementation, quirks included (path reverse traversal,
deny raising PermissionError, pool checks with the "/pool" prefix, group
ACEs in natural order while user ACEs are sorted by descending path).
"""
import logging
from dataclasses import dataclass
from typing import Dict, Iterable, List, Optional, Set, Tuple
from urllib.parse import urlparse
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
import gns3server.db.models as models
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class AceEntry:
"""
A flattened ACE: the ACE row itself plus the ids it matches.
"""
path: str
propagate: bool
allowed: bool
user_id: Optional[UUID]
group_id: Optional[UUID]
role_id: UUID
class RbacState:
"""
Process-wide snapshot of the RBAC tables.
The snapshot is loaded lazily on first use and rebuilt after any
RBAC write (mark_dirty). It must be reset when the database is
recreated (tests) via reset().
"""
_instance: Optional["RbacState"] = None
def __init__(self) -> None:
self._loaded: bool = False
self._dirty: bool = False
self._role_privileges: Dict[UUID, Set[str]] = {}
self._group_members: Dict[UUID, Set[UUID]] = {}
self._aces: List[AceEntry] = []
self._project_resource_ids: Set[str] = set()
# pool id (str) -> project resource ids (str)
self._pool_projects: Dict[str, Set[str]] = {}
@classmethod
def instance(cls) -> "RbacState":
if cls._instance is None:
cls._instance = cls()
return cls._instance
def reset(self) -> None:
"""
Drop the snapshot: the next read reloads it from the database.
Called when the database is recreated (tests) or at startup.
"""
self._loaded = False
self._dirty = False
self._role_privileges = {}
self._group_members = {}
self._aces = []
self._project_resource_ids = set()
self._pool_projects = {}
def mark_dirty(self) -> None:
"""
Flag the snapshot as outdated. Repository write methods call
this after touching an RBAC table; no I/O is done here.
"""
self._dirty = True
async def reload(self, db_session: AsyncSession) -> None:
"""
Rebuild the snapshot from the database, whatever its state.
"""
# clear the flag before loading: if a write marks the snapshot
# dirty again while the queries are awaited, the next read
# triggers another reload
self._dirty = False
# role id -> privilege names
roles_result = await db_session.execute(select(models.Role).options(selectinload(models.Role.privileges)))
role_privileges: Dict[UUID, Set[str]] = {}
for role in roles_result.scalars().all():
role_privileges[role.role_id] = {privilege.name for privilege in role.privileges}
# group id -> member user ids
result = await db_session.execute(select(models.UserGroup).options(selectinload(models.UserGroup.users)))
group_members: Dict[UUID, Set[UUID]] = {}
for group in result.scalars().all():
group_members[group.user_group_id] = {user.user_id for user in group.users}
# ACEs, in natural database order (group ACE evaluation order relies on it)
aces_result = await db_session.execute(select(models.ACE))
aces: List[AceEntry] = []
for ace in aces_result.scalars().all():
# ACE uses legacy Column declarations, so typed attribute access
# on instances is not available: go through the plain dict
values = ace.asdict()
aces.append(
AceEntry(
path=values["path"],
propagate=values["propagate"],
allowed=values["allowed"],
user_id=values["user_id"],
group_id=values["group_id"],
role_id=values["role_id"],
)
)
# all registered project resources, pooled or not: the pool gate in
# user_has_privilege checks paths against every project resource
resources_result = await db_session.execute(
select(models.Resource)
.options(selectinload(models.Resource.resource_pools))
.filter(models.Resource.resource_type == "project")
)
project_resource_ids: Set[str] = set()
pool_projects: Dict[str, Set[str]] = {}
for resource in resources_result.scalars().all():
project_resource_ids.add(str(resource.resource_id))
for pool in resource.resource_pools:
pool_projects.setdefault(str(pool.resource_pool_id), set()).add(str(resource.resource_id))
self._role_privileges = role_privileges
self._group_members = group_members
self._aces = aces
self._project_resource_ids = project_resource_ids
self._pool_projects = pool_projects
self._loaded = True
log.debug(f"RBAC snapshot reloaded: {len(aces)} ACE(s), {len(role_privileges)} role(s)")
async def ensure_loaded(self, db_session: AsyncSession) -> None:
"""
Rebuild the snapshot if it has never been loaded or is dirty.
"""
if not self._loaded or self._dirty:
await self.reload(db_session)
#
# Evaluation methods below are pure: no database access.
#
def _user_aces(self, user_id: UUID, privilege_name: str) -> List[AceEntry]:
"""
ACEs assigned to the user itself granting the privilege,
sorted by descending path (same order as the SQL implementation).
"""
aces = [
ace
for ace in self._aces
if ace.user_id == user_id and privilege_name in self._role_privileges.get(ace.role_id, ())
]
return sorted(aces, key=lambda ace: ace.path, reverse=True)
def _group_aces(self, user_id: UUID, privilege_name: str) -> List[AceEntry]:
"""
ACEs assigned to the groups the user belongs to granting the
privilege, in natural order (same order as the SQL implementation).
"""
groups = {group_id for group_id, members in self._group_members.items() if user_id in members}
return [
ace
for ace in self._aces
if ace.group_id is not None
and ace.group_id in groups
and privilege_name in self._role_privileges.get(ace.role_id, ())
]
@staticmethod
def _check_path_with_aces(path: str, aces: Iterable[AceEntry]) -> bool:
"""
Compare path with existing ACEs to check if the user has the required privilege on that path.
"""
parsed_url = urlparse(path)
original_path = path
path_components = parsed_url.path.split("/")
# traverse the path in reverse order
for i in range(len(path_components), 0, -1):
path = "/".join(path_components[:i])
if not path:
path = "/"
for ace in aces:
if ace.path == path:
if not ace.allowed:
raise PermissionError(f"Permission denied for {path}")
if path == original_path or ace.propagate:
return True # only allow if the path is the original path or the ACE is set to propagate
return False
def _pool_grants_path(self, aces: Iterable[AceEntry], path: str) -> bool:
"""
Whether any pool referenced by the given ACEs contains a project
matching the path (the previous implementation returned the list
of matching resources; only its truthiness was used).
"""
for ace in aces:
if ace.path.startswith("/pool"):
pool_id = ace.path.split("/")[2]
for project_id in self._pool_projects.get(pool_id, ()):
if path.startswith(f"/projects/{project_id}"):
return True
return False
def user_has_privilege(self, user_id: UUID, path: str, privilege_name: str) -> bool:
"""
Check the user has a privilege on a path.
Resource paths form a file system like tree and privileges can be inherited by paths down that tree
(the propagate field is True by default)
The following inheritance rules are used:
* Privileges for individual users always replace group privileges.
* Privileges for groups apply when the user is member of that group.
* Privileges on deeper levels replace those inherited from an upper level.
"""
path_is_in_pool = any(path.startswith(f"/projects/{resource_id}") for resource_id in self._project_resource_ids)
user_aces = self._user_aces(user_id, privilege_name)
try:
# Check regular ACEs first
if self._check_path_with_aces(path, user_aces):
# the user has an ACE matching the path and privilege, there is no need to check group ACEs
return True
# Then check resource pool ACEs
if path_is_in_pool:
if self._pool_grants_path(user_aces, path):
return True
except PermissionError:
return False
group_aces = self._group_aces(user_id, privilege_name)
try:
# Check regular ACEs first
if self._check_path_with_aces(path, group_aces):
return True
# Then check resource pool ACEs
if path_is_in_pool:
if self._pool_grants_path(group_aces, path):
return True
except PermissionError:
return False
return False
def user_privilege_names(self, user_id: UUID) -> Set[str]:
"""
Names of all privileges granted to a user through its roles, via
ACEs on the user itself and on the groups the user belongs to.
Flattened view that ignores resource paths.
"""
groups = {group_id for group_id, members in self._group_members.items() if user_id in members}
names: Set[str] = set()
for ace in self._aces:
if not ace.allowed:
continue
if ace.user_id == user_id or (ace.group_id is not None and ace.group_id in groups):
names.update(self._role_privileges.get(ace.role_id, ()))
return names
def _direct_project_ids(
self, user_aces: List[AceEntry], group_aces: List[AceEntry], all_project_ids: List[str]
) -> Tuple[Set[str], Set[str]]:
"""
Projects the user can access via a direct ACE on /projects/<id>:
user ACEs first (a deny puts the project aside as denied), then
group ACEs skipping already accessible or denied projects.
"""
direct_ace_ids = set()
user_denied = set()
# --- User ACE: direct path check ---
for pid in all_project_ids:
path = f"/projects/{pid}"
try:
if self._check_path_with_aces(path, user_aces):
direct_ace_ids.add(pid)
except PermissionError:
user_denied.add(pid)
# --- Group ACE: direct path check (skip already accessible or denied) ---
for pid in all_project_ids:
if pid in direct_ace_ids or pid in user_denied:
continue
path = f"/projects/{pid}"
try:
if self._check_path_with_aces(path, group_aces):
direct_ace_ids.add(pid)
except PermissionError:
pass
return direct_ace_ids, user_denied
def _pooled_project_ids(
self, user_aces: List[AceEntry], group_aces: List[AceEntry], user_denied: Set[str]
) -> Set[str]:
"""
Projects the user can access because they sit in a resource pool
referenced by an allowed ACE: user pools first, then group pools.
"""
# --- User ACE: pool check ---
user_pool_ids = {ace.path.split("/")[2] for ace in user_aces if ace.path.startswith("/pools/") and ace.allowed}
pool_accessible_ids = set()
for pool_id, project_ids in self._pool_projects.items():
if pool_id in user_pool_ids:
for pid in project_ids:
if pid not in user_denied:
pool_accessible_ids.add(pid)
# --- Group ACE: pool check ---
group_pool_ids = {
ace.path.split("/")[2] for ace in group_aces if ace.path.startswith("/pools/") and ace.allowed
}
for pool_id, project_ids in self._pool_projects.items():
if pool_id in group_pool_ids:
for pid in project_ids:
if pid not in user_denied and pid not in pool_accessible_ids:
pool_accessible_ids.add(pid)
return pool_accessible_ids
def accessible_project_ids(
self, user_id: UUID, privilege_name: str, all_project_ids: List[str]
) -> Tuple[Set[str], Set[str]]:
"""
Batch check which projects a user can access via direct ACE or resource pools.
Returns:
(direct_ace_ids, pool_accessible_ids) where:
- direct_ace_ids: projects the user has a direct ACE on (needs created_by filter)
- pool_accessible_ids: projects in resource pools the user can access (no created_by filter)
"""
user_aces = self._user_aces(user_id, privilege_name)
group_aces = self._group_aces(user_id, privilege_name)
direct_ace_ids, user_denied = self._direct_project_ids(user_aces, group_aces, all_project_ids)
pool_accessible_ids = self._pooled_project_ids(user_aces, group_aces, user_denied)
return direct_ace_ids, pool_accessible_ids
+9
View File
@@ -26,6 +26,7 @@ from sqlalchemy.orm import selectinload
import gns3server.db.models as models
from gns3server import schemas
from gns3server.db.rbac_state import RbacState
from .base import BaseRepository
@@ -36,6 +37,7 @@ class ResourcePoolsRepository(BaseRepository):
def __init__(self, db_session: AsyncSession) -> None:
super().__init__(db_session)
self._rbac_state = RbacState.instance()
async def get_resource(self, resource_id: UUID) -> Optional[models.Resource]:
"""
@@ -65,6 +67,7 @@ class ResourcePoolsRepository(BaseRepository):
)
self._db_session.add(db_resource)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(db_resource)
return db_resource
@@ -76,6 +79,7 @@ class ResourcePoolsRepository(BaseRepository):
query = delete(models.Resource).where(models.Resource.resource_id == resource_id)
result = await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
return cast(CursorResult, result).rowcount > 0
async def get_resource_memberships(self, resource_id: UUID) -> List[models.ResourcePool]:
@@ -127,6 +131,7 @@ class ResourcePoolsRepository(BaseRepository):
db_resource_pool = models.ResourcePool(name=resource_pool.name)
self._db_session.add(db_resource_pool)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(db_resource_pool)
return db_resource_pool
@@ -146,6 +151,7 @@ class ResourcePoolsRepository(BaseRepository):
await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
resource_pool_db = await self.get_resource_pool(resource_pool_id)
if resource_pool_db:
await self._db_session.refresh(resource_pool_db) # force refresh of updated_at value
@@ -167,6 +173,7 @@ class ResourcePoolsRepository(BaseRepository):
query = delete(models.ResourcePool).where(models.ResourcePool.resource_pool_id == resource_pool_id)
result = await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
return cast(CursorResult, result).rowcount > 0
async def add_resource_to_pool(
@@ -188,6 +195,7 @@ class ResourcePoolsRepository(BaseRepository):
resource_pool_db.resources.append(resource)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(resource_pool_db)
return resource_pool_db
@@ -210,6 +218,7 @@ class ResourcePoolsRepository(BaseRepository):
resource_pool_db.resources.remove(resource)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(resource_pool_db)
return resource_pool_db
+33 -202
View File
@@ -16,17 +16,17 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
from typing import Any, List, Optional, Union, cast
from urllib.parse import urlparse
from typing import List, Optional, Set, Union, cast
from uuid import UUID
from sqlalchemy import Select, delete, select, update
from sqlalchemy import delete, select, update
from sqlalchemy.engine import CursorResult
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
import gns3server.db.models as models
from gns3server import schemas
from gns3server.db.rbac_state import RbacState
from .base import BaseRepository
@@ -37,6 +37,7 @@ class RbacRepository(BaseRepository):
def __init__(self, db_session: AsyncSession) -> None:
super().__init__(db_session)
self._rbac_state = RbacState.instance()
async def get_role(self, role_id: UUID) -> Optional[models.Role]:
"""
@@ -76,6 +77,7 @@ class RbacRepository(BaseRepository):
)
self._db_session.add(db_role)
await self._db_session.commit()
self._rbac_state.mark_dirty()
return await self.get_role(db_role.role_id)
async def update_role(self, role_id: UUID, role_update: schemas.RoleUpdate) -> Optional[models.Role]:
@@ -88,6 +90,7 @@ class RbacRepository(BaseRepository):
await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
role_db = await self.get_role(role_id)
if role_db:
await self._db_session.refresh(role_db) # force refresh of updated_at value
@@ -101,6 +104,7 @@ class RbacRepository(BaseRepository):
query = delete(models.Role).where(models.Role.role_id == role_id)
result = await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
return cast(CursorResult, result).rowcount > 0
async def add_privilege_to_role(self, role_id: UUID, privilege: models.Privilege) -> Union[None, models.Role]:
@@ -123,6 +127,7 @@ class RbacRepository(BaseRepository):
role_db.privileges.append(privilege)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(role_db)
return role_db
@@ -139,6 +144,7 @@ class RbacRepository(BaseRepository):
role_db.privileges.remove(privilege)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(role_db)
return role_db
@@ -179,6 +185,18 @@ class RbacRepository(BaseRepository):
result = await self._db_session.execute(query)
return list(result.scalars().all())
async def get_user_privileges(self, user_id: UUID) -> Set[str]:
"""
Get the names of all privileges granted to a user through its roles,
via ACEs on the user itself and on the groups the user belongs to.
This is a flattened view that ignores resource paths: the authoritative
check remains check_user_has_privilege(), which takes paths into account.
"""
await self._rbac_state.ensure_loaded(self._db_session)
return self._rbac_state.user_privilege_names(user_id)
async def get_ace(self, ace_id: UUID) -> Optional[models.ACE]:
"""
Get an ACE by its ID.
@@ -239,6 +257,7 @@ class RbacRepository(BaseRepository):
db_ace = models.ACE(**create_values)
self._db_session.add(db_ace)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(db_ace)
return db_ace
@@ -252,6 +271,7 @@ class RbacRepository(BaseRepository):
await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
ace_db = await self.get_ace(ace_id)
if ace_db:
await self._db_session.refresh(ace_db) # force refresh of updated_at value
@@ -265,6 +285,7 @@ class RbacRepository(BaseRepository):
query = delete(models.ACE).where(models.ACE.ace_id == ace_id)
result = await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
return cast(CursorResult, result).rowcount > 0
async def delete_all_ace_starting_with_path(self, path: str) -> None:
@@ -274,109 +295,13 @@ class RbacRepository(BaseRepository):
query = delete(models.ACE).where(models.ACE.path.startswith(path)).execution_options(synchronize_session=False)
result = await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
log.debug(f"{cast(CursorResult, result).rowcount} ACE(s) have been deleted")
@staticmethod
def _check_path_with_aces(path: str, aces) -> bool:
"""
Compare path with existing ACEs to check if the user has the required privilege on that path.
"""
parsed_url = urlparse(path)
original_path = path
path_components = parsed_url.path.split("/")
# traverse the path in reverse order
for i in range(len(path_components), 0, -1):
path = "/".join(path_components[:i])
if not path:
path = "/"
for ace_path, ace_propagate, ace_allowed, ace_privilege in aces:
if ace_path == path:
if not ace_allowed:
raise PermissionError(f"Permission denied for {path}")
if path == original_path or ace_propagate:
return True # only allow if the path is the original path or the ACE is set to propagate
return False
async def _get_resources_in_pools(self, aces, path: Optional[str] = None) -> List[models.Resource]:
"""
Get all resources in pools.
"""
pool_resources = []
for ace_path, ace_propagate, ace_allowed, ace_privilege in aces:
if ace_path.startswith("/pool"):
resource_pool_id = ace_path.split("/")[2]
query = (
select(models.Resource)
.join(models.Resource.resource_pools)
.filter(models.ResourcePool.resource_pool_id == resource_pool_id)
)
result = await self._db_session.execute(query)
resources = result.scalars().all()
for resource in resources:
# we only support projects in resource pools for now
if resource.resource_type == "project":
if path:
if path.startswith(f"/projects/{resource.resource_id}"):
pool_resources.append(resource)
else:
pool_resources.append(resource)
return pool_resources
async def _get_user_aces(self, user_id: UUID, privilege_name: str):
"""
Retrieve all user ACEs matching the user_id and privilege name.
"""
query: Select[Any, Any, Any, Any] = (
select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name)
.join(models.Privilege.roles)
.join(models.Role.acl_entries)
.join(models.ACE.user)
.filter(models.User.user_id == user_id)
.filter(models.Privilege.name == privilege_name)
.order_by(models.ACE.path.desc())
)
result = await self._db_session.execute(query)
return result.all()
async def _get_group_aces(self, user_id: UUID, privilege_name: str):
"""
Retrieve all group ACEs matching the user_id and privilege name.
"""
query: Select[Any, Any, Any, Any] = (
select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name)
.join(models.Privilege.roles)
.join(models.Role.acl_entries)
.join(models.ACE.group)
.join(models.UserGroup.users)
.filter(models.User.user_id == user_id)
.filter(models.Privilege.name == privilege_name)
)
result = await self._db_session.execute(query)
return result.all()
async def get_user_pool_resources(self, user_id: UUID, privilege_name: str) -> List[models.Resource]:
"""
Get all resources in pools belonging to a user and groups
"""
user_aces = await self._get_user_aces(user_id, privilege_name)
pool_resources = await self._get_resources_in_pools(user_aces)
group_aces = await self._get_group_aces(user_id, privilege_name)
pool_resources.extend(await self._get_resources_in_pools(group_aces))
return list(set(pool_resources))
async def get_accessible_project_ids(self, user_id: UUID, privilege_name: str, all_project_ids: List[str]):
"""
Batch check which projects a user can access via direct ACE or resource pools.
Performs 3 fixed DB queries regardless of project count.
Returns:
(direct_ace_ids, pool_accessible_ids) where:
@@ -384,72 +309,8 @@ class RbacRepository(BaseRepository):
- pool_accessible_ids: projects in resource pools the user can access (no created_by filter)
"""
user_aces = await self._get_user_aces(user_id, privilege_name)
group_aces = await self._get_group_aces(user_id, privilege_name)
# Single query for all resources and their pool memberships
query = select(models.Resource).options(selectinload(models.Resource.resource_pools))
result = await self._db_session.execute(query)
all_resources = result.scalars().all()
# Precompute pool_id -> set of project_ids
pool_to_projects: dict[str, set[str]] = {}
for r in all_resources:
if r.resource_type == "project":
for pool in r.resource_pools:
pool_to_projects.setdefault(str(pool.resource_pool_id), set()).add(str(r.resource_id))
# --- User ACE: direct path check ---
direct_ace_ids = set()
user_denied = set()
for pid in all_project_ids:
path = f"/projects/{pid}"
try:
if self._check_path_with_aces(path, user_aces):
direct_ace_ids.add(pid)
except PermissionError:
user_denied.add(pid)
# --- User ACE: pool check ---
user_pool_ids = {
ace_path.split("/")[2]
for ace_path, _, ace_allowed, _ in user_aces
if ace_path.startswith("/pools/") and ace_allowed
}
pool_accessible_ids = set()
for pool_id, project_ids in pool_to_projects.items():
if pool_id in user_pool_ids:
for pid in project_ids:
if pid not in user_denied:
pool_accessible_ids.add(pid)
# --- Group ACE: direct path check (skip already accessible or denied) ---
for pid in all_project_ids:
if pid in direct_ace_ids or pid in user_denied:
continue
path = f"/projects/{pid}"
try:
if self._check_path_with_aces(path, group_aces):
direct_ace_ids.add(pid)
except PermissionError:
pass
# --- Group ACE: pool check ---
group_pool_ids = {
ace_path.split("/")[2]
for ace_path, _, ace_allowed, _ in group_aces
if ace_path.startswith("/pools/") and ace_allowed
}
for pool_id, project_ids in pool_to_projects.items():
if pool_id in group_pool_ids:
for pid in project_ids:
if pid not in user_denied and pid not in pool_accessible_ids:
pool_accessible_ids.add(pid)
return direct_ace_ids, pool_accessible_ids
await self._rbac_state.ensure_loaded(self._db_session)
return self._rbac_state.accessible_project_ids(user_id, privilege_name, all_project_ids)
async def check_user_has_privilege(self, user_id: UUID, path: str, privilege_name: str) -> bool:
"""
@@ -461,40 +322,10 @@ class RbacRepository(BaseRepository):
* Privileges for individual users always replace group privileges.
* Privileges for groups apply when the user is member of that group.
* Privileges on deeper levels replace those inherited from an upper level.
The check runs against the in-memory RBAC snapshot (rebuilt after
any RBAC write), see gns3server.db.rbac_state.
"""
query = select(models.Resource)
result = await self._db_session.execute(query)
resources = result.scalars().all()
projects_in_pools = [f"/projects/{r.resource_id}" for r in resources if r.resource_type == "project"]
path_is_in_pool = False
for project_in_pool in projects_in_pools:
if path.startswith(project_in_pool):
path_is_in_pool = True
break
aces = await self._get_user_aces(user_id, privilege_name)
try:
# Check regular ACEs first
if self._check_path_with_aces(path, aces):
# the user has an ACE matching the path and privilege, there is no need to check group ACEs
return True
# Then check resource pool ACEs
if path_is_in_pool:
if await self._get_resources_in_pools(aces, path):
return True
except PermissionError:
return False
aces = await self._get_group_aces(user_id, privilege_name)
try:
# Check regular ACEs first
if self._check_path_with_aces(path, aces):
return True
# Then check resource pool ACEs
if path_is_in_pool:
if await self._get_resources_in_pools(aces, path):
return True
except PermissionError:
return False
return False
await self._rbac_state.ensure_loaded(self._db_session)
return self._rbac_state.user_has_privilege(user_id, path, privilege_name)
+10
View File
@@ -26,6 +26,7 @@ from sqlalchemy.orm import selectinload
import gns3server.db.models as models
from gns3server import schemas
from gns3server.db.rbac_state import RbacState
from gns3server.services import auth_service
from .base import BaseRepository
@@ -38,6 +39,7 @@ class UsersRepository(BaseRepository):
super().__init__(db_session)
self._auth_service = auth_service
self._rbac_state = RbacState.instance()
async def get_user(self, user_id: UUID) -> Optional[models.User]:
"""
@@ -86,6 +88,7 @@ class UsersRepository(BaseRepository):
)
self._db_session.add(db_user)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(db_user)
return db_user
@@ -105,6 +108,7 @@ class UsersRepository(BaseRepository):
await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
user_db = await self.get_user(user_id)
if user_db:
await self._db_session.refresh(user_db) # force refresh of updated_at value
@@ -132,6 +136,7 @@ class UsersRepository(BaseRepository):
query = delete(models.User).where(models.User.user_id == user_id)
result = await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
return cast(CursorResult, result).rowcount > 0
async def authenticate_user(self, username: str, password: str) -> Optional[models.User]:
@@ -208,6 +213,7 @@ class UsersRepository(BaseRepository):
db_user_group = models.UserGroup(name=user_group.name)
self._db_session.add(db_user_group)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(db_user_group)
return db_user_group
@@ -223,6 +229,7 @@ class UsersRepository(BaseRepository):
await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
user_group_db = await self.get_user_group(user_group_id)
if user_group_db:
await self._db_session.refresh(user_group_db) # force refresh of updated_at value
@@ -236,6 +243,7 @@ class UsersRepository(BaseRepository):
query = delete(models.UserGroup).where(models.UserGroup.user_group_id == user_group_id)
result = await self._db_session.execute(query)
await self._db_session.commit()
self._rbac_state.mark_dirty()
return cast(CursorResult, result).rowcount > 0
async def add_member_to_user_group(self, user_group_id: UUID, user: models.User) -> Union[None, models.UserGroup]:
@@ -255,6 +263,7 @@ class UsersRepository(BaseRepository):
user_group_db.users.append(user)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(user_group_db)
return user_group_db
@@ -277,6 +286,7 @@ class UsersRepository(BaseRepository):
user_group_db.users.remove(user)
await self._db_session.commit()
self._rbac_state.mark_dirty()
await self._db_session.refresh(user_group_db)
return user_group_db
+11
View File
@@ -32,6 +32,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from gns3server import schemas
from gns3server.config import Config
from gns3server.db.rbac_state import RbacState
from gns3server.db.repositories.computes import ComputesRepository
from gns3server.db.repositories.images import ImagesRepository
from gns3server.utils.images import read_image_info
@@ -199,6 +200,16 @@ async def get_computes(app: FastAPI) -> List[schemas.Compute]:
return computes
async def load_rbac_state(app: FastAPI) -> None:
"""
Load the in-memory RBAC snapshot at startup, so permission checks do
not hit the database on the request path.
"""
async with AsyncSession(app.state._db_engine) as db_session:
await RbacState.instance().reload(db_session)
async def update_disk_checksums(updated_disks: List[str]) -> None:
"""Refresh complete metadata after a server-managed disk modification."""
from gns3server.api.server import app
+4
View File
@@ -71,6 +71,7 @@ from .controller.projects import (
ProjectCreate,
ProjectDuplicate,
ProjectFile,
ProjectPrivileges,
ProjectStats,
ProjectUpdate,
)
@@ -83,6 +84,7 @@ from .controller.users import (
UserGroup,
UserGroupCreate,
UserGroupUpdate,
UserPrivileges,
UserUpdate,
)
from .version import Version
@@ -350,6 +352,7 @@ __all__ = [
"ProjectDuplicate",
"ProjectFile",
"ProjectMarker",
"ProjectPrivileges",
"ProjectStats",
"ProjectUpdate",
"Qemu",
@@ -397,6 +400,7 @@ __all__ = [
"UserGroup",
"UserGroupCreate",
"UserGroupUpdate",
"UserPrivileges",
"UserUpdate",
"VMware",
"VMwareCreate",
@@ -146,6 +146,9 @@ class ComputeDockerImage(BaseModel):
"""
image: str = Field(..., description="Docker image name")
id: Optional[str] = Field(None, description="Docker image id reported by the daemon")
size: Optional[int] = Field(None, description="Docker image size in bytes")
created: Optional[Union[int, str]] = Field(None, description="Docker image creation date (unix timestamp)")
class AutoIdlePC(BaseModel):
+15
View File
@@ -139,3 +139,18 @@ class ProjectStats(BaseModel):
drawings: int
snapshots: int
markers: int
class ProjectPrivileges(BaseModel):
"""
Effective privileges of the logged in user on a single project, evaluated
on the project's resource path (so ACEs scoped to that project and
resource pools containing it are reflected).
This is a hint for rendering purposes only (e.g. showing or hiding buttons
in the project view): the server remains the authority for permission
checks on each request.
"""
is_superadmin: bool
privileges: List[str]
+15 -1
View File
@@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from datetime import datetime
from typing import Optional
from typing import List, Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr, Field, SecretStr
@@ -69,6 +69,20 @@ class User(DateTimeModelMixin, UserBase):
model_config = ConfigDict(from_attributes=True)
class UserPrivileges(BaseModel):
"""
Effective privileges of the logged in user, flattened across all roles
assigned to the user (directly or through group membership).
This is a hint for rendering purposes only: the server remains the
authority for permission checks and can still deny a privilege listed
here on a specific resource path.
"""
is_superadmin: bool
privileges: List[str]
class UserGroupBase(BaseModel):
"""
Common user group properties.
+25 -9
View File
@@ -42,6 +42,12 @@ from gns3server.version import __version__
log = logging.getLogger(__name__)
# Uvicorn waits for open connections forever by default. A long-lived
# streaming response (for example the MCP GET/SSE stream) would then block
# Ctrl+C indefinitely. Bound the wait so remaining tasks are cancelled and
# the lifespan shutdown (controller and project cleanup) always runs.
GRACEFUL_SHUTDOWN_TIMEOUT = 10
class Server:
_stream_handler = None
@@ -222,6 +228,24 @@ class Server:
log.critical("Can't write pid file %s: %s", path, str(e))
sys.exit(1)
@staticmethod
def _create_uvicorn_config(app, host: str, port: int, access_log: bool) -> uvicorn.Config:
"""
Build the uvicorn configuration.
"""
server_config = Config.instance().settings.Server
return uvicorn.Config(
app,
host=host,
port=port,
access_log=access_log,
ssl_certfile=server_config.certfile,
ssl_keyfile=server_config.certkey,
lifespan="on",
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
)
async def run(self, parser, args):
self._setup_logging(args)
@@ -301,15 +325,7 @@ class Server:
if config.Server.enable_ssl:
log.info("SSL is enabled")
config = uvicorn.Config(
app,
host=host,
port=port,
access_log=access_log,
ssl_certfile=config.Server.certfile,
ssl_keyfile=config.Server.certkey,
lifespan="on",
)
config = self._create_uvicorn_config(app, host, port, access_log)
# overwrite uvicorn loggers with our own logger
for uvicorn_logger_name in ("uvicorn", "uvicorn.error"):
+27 -1
View File
@@ -47,8 +47,34 @@ def utcnow():
return datetime.now(UTC).replace(tzinfo=None)
# Files that are never images: scripts, documentation and certificates users
# keep next to their images (e.g. the Cisco IOU keygen script). Skipping them
# keeps them out of the catalog and stops them from triggering rescans.
IGNORED_EXTENSIONS = {
".cfg",
".conf",
".crt",
".json",
".key",
".lic",
".license",
".log",
".md",
".pem",
".py",
".sh",
".txt",
".yaml",
".yml",
}
def ignored(name):
return name.startswith(".") or name.endswith((".tmp", ".md5sum"))
return (
name.startswith(".")
or name.endswith((".tmp", ".md5sum"))
or os.path.splitext(name)[1].lower() in IGNORED_EXTENSIONS
)
def configured_roots():
+56
View File
@@ -53,6 +53,62 @@ log = logging.getLogger(__name__)
# Include the first ISO 9660 volume descriptor in image type detection.
IMAGE_HEADER_SIZE = 16 * 2048 + 7
# A Docker image reference: name, optional registry, tag and digest. Callers
# must also reject ".." (not expressible here, the pydantic regex engine has no
# lookaround) so a name cannot escape the daemon API path it is interpolated
# into: see validate_docker_image_name.
DOCKER_IMAGE_NAME_PATTERN = r"^[a-zA-Z0-9][a-zA-Z0-9._/:@-]*$"
def validate_docker_image_name(image: str) -> None:
"""
Reject Docker image names that could escape the daemon API path they are
interpolated into.
:raises ValueError: when the name is not safe
"""
if ".." in image:
raise ValueError(f"Invalid Docker image name: {image}")
def canonical_docker_image_name(image: str) -> str:
"""
Normalize a Docker image reference for name comparisons.
Registry hosts are dropped, Docker Hub's implicit "library/" namespace and
the implicit ":latest" tag are made explicit, so "alpine",
"library/alpine:latest" and "docker.io/library/alpine" all normalize to
the same string. A reference pinned by digest keeps its digest and loses
its tag.
"""
reference = image.strip()
if "/" in reference:
registry, _, remainder = reference.partition("/")
if registry == "localhost" or "." in registry or ":" in registry:
# the first path element is a registry host, not a namespace
reference = remainder
if "@" in reference:
repository, _, digest = reference.rpartition("@")
if not repository:
return reference # a bare digest, nothing to normalize
# with a digest pinned the tag does not take part in the identity
repository = repository.partition(":")[0]
return f"{_canonical_docker_repository(repository)}@{digest}"
repository, separator, tag = reference.rpartition(":")
if not separator or not repository:
repository, tag = reference, "latest"
return f"{_canonical_docker_repository(repository)}:{tag or 'latest'}"
def _canonical_docker_repository(repository: str) -> str:
if "/" not in repository:
# images without a namespace live in Docker Hub's "library/" namespace
return f"library/{repository}"
return repository
async def list_images(image_type):
"""
+2 -1
View File
@@ -180,7 +180,8 @@ extend-immutable-calls = [
"gns3server/api/routes/controller/templates.py" = ["S324"]
"gns3server/api/routes/controller/users.py" = ["S105", "S106"]
"gns3server/compute/base_node.py" = ["C901", "RUF019"]
"gns3server/compute/docker/__init__.py" = ["B006", "C901"]
"gns3server/compute/docker/__init__.py" = ["B006"]
"gns3server/compute/docker/docker_images.py" = ["C901"]
"gns3server/compute/docker/docker_vm.py" = ["B006", "C901", "E731", "RUF003", "RUF006", "S108"]
"gns3server/compute/docker/iol_docker_vm.py" = ["RUF002", "S108"]
"gns3server/compute/docker/vendor_docker_vm.py" = ["RUF002", "S108"]
+2 -2
View File
@@ -3,7 +3,7 @@ uvicorn==0.54.0
pydantic==2.14.2
fastapi==0.162.0
python-multipart==0.0.33
websockets==16.1.1 # google-genai 2.10.0 has requirement websockets<17.0
websockets==17.4
aiohttp>=3.14.5,<3.15
aiofiles>=25.1.0,<26.0
Jinja2>=3.1.6,<3.2
@@ -11,7 +11,7 @@ sentry-sdk>=2.73.0,<3 # optional dependency
psutil>=7.2.2
distro>=1.9.0
py-cpuinfo>=9.0.0,<10.0
sqlalchemy[asyncio]==2.1.6
sqlalchemy[asyncio]==2.1.8
aiosqlite==0.22.1
alembic==1.20.1
bcrypt==5.0.0
+96 -5
View File
@@ -18,6 +18,7 @@ import pytest
from fastapi import FastAPI, status
from httpx import AsyncClient
from gns3server.compute.docker.docker_error import DockerError
from tests.utils import asyncio_patch
pytestmark = pytest.mark.asyncio
@@ -26,12 +27,75 @@ pytestmark = pytest.mark.asyncio
class TestImagesRoutes:
async def test_pull_docker_image(self, app: FastAPI, compute_client: AsyncClient) -> None:
with asyncio_patch("gns3server.compute.docker.Docker.pull_image") as mock:
response = await compute_client.post(
app.url_path_for("compute:pull_docker_image"), json={"image": "nginx:latest"}
async def fake_pull(image, progress_callback=None, force=False):
progress_callback(
{"layers_total": 0, "layers_done": 0, "message": "Pulling 'nginx:latest'", "state": "started"}
)
mock.assert_called_once_with("nginx:latest", force=True)
assert response.status_code == status.HTTP_204_NO_CONTENT
with asyncio_patch("gns3server.compute.docker.Docker.pull_image", side_effect=fake_pull) as mock:
with asyncio_patch("gns3server.compute.notification_manager.NotificationManager.emit") as emit_mock:
response = await compute_client.post(
app.url_path_for("compute:pull_docker_image"), json={"image": "nginx:latest"}
)
assert mock.call_args[0] == ("nginx:latest",)
assert mock.call_args[1]["force"] is True
assert callable(mock.call_args[1]["progress_callback"])
# started and completed events are emitted to the notification stream
assert [(c.args[0], c.args[1]["state"]) for c in emit_mock.call_args_list] == [
("docker.image.pull", "started"),
("docker.image.pull", "completed"),
]
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_pull_docker_image_progress_id(self, app: FastAPI, compute_client: AsyncClient) -> None:
async def fake_pull(image, progress_callback=None, force=False):
progress_callback({"layers_total": 1, "layers_done": 0, "message": "Pulling", "state": "started"})
progress_callback({"layers_total": 1, "layers_done": 0, "message": "[==> ] 10/100"})
with asyncio_patch("gns3server.compute.docker.Docker.pull_image", side_effect=fake_pull):
with asyncio_patch("gns3server.compute.notification_manager.NotificationManager.emit") as emit_mock:
response = await compute_client.post(
app.url_path_for("compute:pull_docker_image"),
json={"image": "nginx:latest"},
params={"progress_id": "abc123"},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert all(c.args[1]["progress_id"] == "abc123" for c in emit_mock.call_args_list)
async def test_pull_docker_image_reports_downloading(self, app: FastAPI, compute_client: AsyncClient) -> None:
async def fake_pull(image, progress_callback=None, force=False):
progress_callback({"layers_total": 2, "layers_done": 0, "message": "Pulling", "state": "started"})
progress_callback({"layers_total": 2, "layers_done": 1, "message": "[==> ] 40/100"})
with asyncio_patch("gns3server.compute.docker.Docker.pull_image", side_effect=fake_pull):
with asyncio_patch("gns3server.compute.notification_manager.NotificationManager.emit") as emit_mock:
response = await compute_client.post(
app.url_path_for("compute:pull_docker_image"), json={"image": "nginx:latest"}
)
assert response.status_code == status.HTTP_204_NO_CONTENT
states = [c.args[1]["state"] for c in emit_mock.call_args_list]
assert states == ["started", "downloading", "completed"]
downloading = emit_mock.call_args_list[1].args[1]
assert downloading["layers_total"] == 2
assert downloading["layers_done"] == 1
assert downloading["message"] == "[==> ] 40/100"
async def test_pull_docker_image_emits_failed(self, app: FastAPI, compute_client: AsyncClient) -> None:
async def fake_pull(image, progress_callback=None, force=False):
raise DockerError("no data received for 60 seconds")
with asyncio_patch("gns3server.compute.docker.Docker.pull_image", side_effect=fake_pull):
with asyncio_patch("gns3server.compute.notification_manager.NotificationManager.emit") as emit_mock:
response = await compute_client.post(
app.url_path_for("compute:pull_docker_image"), json={"image": "nginx:latest"}
)
assert response.status_code == status.HTTP_409_CONFLICT
failed = emit_mock.call_args_list[-1].args[1]
assert failed["state"] == "failed"
assert "no data received" in failed["message"]
@pytest.mark.parametrize("image", ["", " ", "nginx latest"])
async def test_pull_docker_image_rejects_invalid_name(
@@ -55,6 +119,33 @@ class TestImagesRoutes:
assert hasattr(stream, "__aiter__")
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_delete_docker_image(self, app: FastAPI, compute_client: AsyncClient) -> None:
with asyncio_patch("gns3server.compute.docker.Docker.delete_image") as mock:
response = await compute_client.delete(
app.url_path_for("compute:delete_docker_image", image="ios-xr/xrd-control-plane:24.4.1")
)
mock.assert_called_once_with("ios-xr/xrd-control-plane:24.4.1", force=False)
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_delete_docker_image_rejects_path_traversal(self, app: FastAPI, compute_client: AsyncClient) -> None:
# percent encoded so the client does not normalize the ".." segments
# away before the request reaches the server
with asyncio_patch("gns3server.compute.docker.Docker.delete_image") as mock:
response = await compute_client.delete("/v3/compute/docker/images/a%2F..%2F..%2Fversion")
mock.assert_not_called()
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
async def test_delete_docker_image_force(self, app: FastAPI, compute_client: AsyncClient) -> None:
with asyncio_patch("gns3server.compute.docker.Docker.delete_image") as mock:
response = await compute_client.delete(
app.url_path_for("compute:delete_docker_image", image="ubuntu:latest"),
params={"force": "true"},
)
mock.assert_called_once_with("ubuntu:latest", force=True)
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_load_docker_image_with_image_and_digest(self, app: FastAPI, compute_client: AsyncClient) -> None:
digest = "sha256:" + "a" * 64
+24 -17
View File
@@ -15,23 +15,30 @@
# 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
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
# @pytest.mark.asyncio
# async def test_notification_ws(compute_api):
#
# # FIXME: how to test websockets
# pass
import pytest
# with compute_api.ws("/notifications/ws") as ws:
from gns3server.api.routes.compute import compute_api
from gns3server.api.routes.compute.notifications import project_ws_notifications
# answer = await ws.receive_text()
# print(answer)
# answer = json.loads(answer)
#
# assert answer["action"] == "ping"
#
# NotificationManager.instance().emit("test", {})
#
# answer = await ws.receive_text()
# answer = json.loads(answer)
# assert answer["action"] == "test"
@pytest.mark.asyncio
async def test_compute_ws_stops_when_app_is_exiting() -> None:
# plain assignment: see the note in test_notifications.py — monkeypatch
# cannot round-trip attributes of Starlette's State on pytest 9.2+
compute_api.state.exiting = True
try:
websocket = MagicMock()
websocket.app = compute_api
websocket.client = SimpleNamespace(host="127.0.0.1", port=1000)
websocket.send_text = AsyncMock()
await asyncio.wait_for(project_ws_notifications(websocket=websocket), timeout=1)
websocket.send_text.assert_not_awaited()
finally:
compute_api.state.exiting = False
+186 -2
View File
@@ -182,7 +182,10 @@ class TestComputeFeatures:
) as mock:
response = await client.get(app.url_path_for("docker_get_images", compute_id=compute_id))
mock.assert_called_with("GET", "docker", "images")
assert response.json() == [{"image": "docker1"}, {"image": "docker2"}]
assert response.json() == [
{"image": "docker1", "id": None, "size": None, "created": None},
{"image": "docker2", "id": None, "size": None, "created": None},
]
async def test_compute_pull_docker_image(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None:
@@ -194,7 +197,188 @@ class TestComputeFeatures:
app.url_path_for("docker_pull_image", compute_id=test_compute.compute_id),
json={"image": "nginx:latest"},
)
compute.forward.assert_called_with("POST", "docker", "images/pull", data={"image": "nginx:latest"})
compute.forward.assert_called_with(
"POST", "docker", "images/pull", data={"image": "nginx:latest"}, params=None
)
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_compute_pull_docker_image_progress_id(
self, app: FastAPI, client: AsyncClient, test_compute: Compute
) -> None:
compute = MagicMock()
compute.forward = AsyncioMagicMock(return_value={})
with patch("gns3server.api.routes.controller.computes.Controller.instance") as controller:
controller.return_value.get_compute.return_value = compute
response = await client.post(
app.url_path_for("docker_pull_image", compute_id=test_compute.compute_id),
json={"image": "nginx:latest"},
params={"progress_id": "abc123"},
)
compute.forward.assert_called_with(
"POST",
"docker",
"images/pull",
data={"image": "nginx:latest"},
params={"progress_id": "abc123"},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_compute_delete_docker_image(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None:
compute = MagicMock()
compute.http_query = AsyncioMagicMock(return_value={})
with patch("gns3server.api.routes.controller.computes.Controller.instance") as controller:
controller.return_value.get_compute.return_value = compute
response = await client.delete(
app.url_path_for(
"docker_delete_image",
compute_id=test_compute.compute_id,
image="ios-xr/xrd-control-plane:24.4.1",
),
params={"force": True},
)
compute.http_query.assert_called_once_with(
"DELETE",
"/docker/images/ios-xr/xrd-control-plane:24.4.1",
params={"force": "true"},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_compute_delete_docker_image_rejects_path_traversal(
self, app: FastAPI, client: AsyncClient, test_compute: Compute
) -> None:
# percent encoded so the client does not normalize the ".." segments
# away before the request reaches the server
compute = MagicMock()
compute.http_query = AsyncioMagicMock(return_value={})
with patch("gns3server.api.routes.controller.computes.Controller.instance") as controller:
controller.return_value.get_compute.return_value = compute
response = await client.delete(
f"/v3/computes/{test_compute.compute_id}/docker/images/a%2F..%2F..%2Fversion"
)
compute.http_query.assert_not_called()
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
async def test_compute_delete_docker_image_refused_while_referenced_by_templates(
self, app: FastAPI, client: AsyncClient, test_compute: Compute
) -> None:
"""
A deletion is refused while a template references the image, matched
through the image reference spelling, and the compute is not touched.
"""
new_template = {
"name": "Alpine conflict test",
"compute_id": "local",
# a different spelling of the deleted 'alpine:3'
"image": "docker.io/library/alpine:3",
"template_type": "docker",
}
response = await client.post(app.url_path_for("create_template"), json=new_template)
assert response.status_code == status.HTTP_201_CREATED
compute = MagicMock()
compute.http_query = AsyncioMagicMock(return_value={})
with patch("gns3server.api.routes.controller.computes.Controller.instance") as controller:
controller.return_value.get_compute.return_value = compute
response = await client.delete(
app.url_path_for("docker_delete_image", compute_id=test_compute.compute_id, image="alpine:3")
)
assert response.status_code == status.HTTP_409_CONFLICT
body = response.json()
assert body["code"] == "image_referenced_by_templates"
assert body["details"]["referenced_by_templates"] == ["Alpine conflict test"]
assert "referenced by template(s): Alpine conflict test" in body["message"]
compute.http_query.assert_not_called()
async def test_compute_delete_docker_image_force_overrides_template_reference(
self, app: FastAPI, client: AsyncClient, test_compute: Compute
) -> None:
"""
force=true deletes an image referenced by templates and the deletion
notification reports them.
"""
new_template = {
"name": "Alpine force test",
"compute_id": "local",
# an image no other test in this class references: the assertions
# below are exact and the class shares one database
"image": "gns3/force-delete-test:2",
"template_type": "docker",
}
response = await client.post(app.url_path_for("create_template"), json=new_template)
assert response.status_code == status.HTTP_201_CREATED
compute = MagicMock()
compute.http_query = AsyncioMagicMock(return_value={})
with patch("gns3server.api.routes.controller.computes.Controller.instance") as controller:
controller.return_value.get_compute.return_value = compute
response = await client.delete(
app.url_path_for(
"docker_delete_image", compute_id=test_compute.compute_id, image="gns3/force-delete-test:2"
),
params={"force": True},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
compute.http_query.assert_called_once_with(
"DELETE", "/docker/images/gns3/force-delete-test:2", params={"force": "true"}
)
controller.return_value.notification.controller_emit.assert_called_once_with(
"docker.image.deleted",
{
"compute_id": str(test_compute.compute_id),
"image": "gns3/force-delete-test:2",
"referenced_by_templates": ["Alpine force test"],
},
)
async def test_compute_delete_docker_image_notifies_without_templates(
self, app: FastAPI, client: AsyncClient, test_compute: Compute
) -> None:
"""
A deletion nothing references still emits a docker.image.deleted
notification, so clients can refresh their image lists.
"""
compute = MagicMock()
compute.http_query = AsyncioMagicMock(return_value={})
with patch("gns3server.api.routes.controller.computes.Controller.instance") as controller:
controller.return_value.get_compute.return_value = compute
response = await client.delete(
app.url_path_for(
"docker_delete_image", compute_id=test_compute.compute_id, image="gns3/delete-event-test:1"
)
)
assert response.status_code == status.HTTP_204_NO_CONTENT
controller.return_value.notification.controller_emit.assert_called_once_with(
"docker.image.deleted",
{
"compute_id": str(test_compute.compute_id),
"image": "gns3/delete-event-test:1",
"referenced_by_templates": [],
},
)
async def test_compute_load_docker_image(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None:
compute = MagicMock()
compute.http_query = AsyncioMagicMock(return_value={})
with patch("gns3server.api.routes.controller.computes.Controller.instance") as controller:
controller.return_value.get_compute.return_value = compute
response = await client.post(
app.url_path_for("docker_load_image", compute_id=test_compute.compute_id),
content=b"docker save tar data",
headers={"Content-Type": "application/octet-stream"},
)
compute.http_query.assert_called_once()
args, kwargs = compute.http_query.call_args
assert args[0] == "POST"
assert args[1] == "/docker/images/load"
assert kwargs["timeout"] is None
# the request body must be streamed, not buffered
assert hasattr(kwargs["data"], "__aiter__")
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_compute_list_virtualbox_vms(self, app: FastAPI, client: AsyncClient) -> None:
@@ -20,7 +20,9 @@ from fastapi import FastAPI, status
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.db.repositories.users import UsersRepository
from gns3server.schemas.controller.rbac import ACECreate
from gns3server.schemas.controller.users import User, UserGroupCreate
pytestmark = pytest.mark.asyncio
@@ -84,6 +86,36 @@ class TestGroupRoutes:
response = await client.delete(app.url_path_for("delete_user_group", user_group_id=group_in_db.user_group_id))
assert response.status_code == status.HTTP_403_FORBIDDEN
async def test_delete_group_removes_ace_on_group_path(
self, app: FastAPI, client: AsyncClient, db_session: AsyncSession
) -> None:
"""
A user ACE on the group path is not covered by the group_id foreign key
cascade: deleting the group must remove it by path.
"""
response = await client.post(app.url_path_for("create_user_group"), json={"name": "ace_cleanup_group"})
assert response.status_code == status.HTTP_201_CREATED
group_id = response.json()["user_group_id"]
# any existing user works: the ACE references the user, not the group
user = await UsersRepository(db_session).get_user_by_username("admin")
role_id = (await RbacRepository(db_session).get_role_by_name("User")).role_id
rbac_repo = RbacRepository(db_session)
await rbac_repo.create_ace(
ACECreate(
path=f"/access/groups/{group_id}",
ace_type="user",
propagate=False,
user_id=str(user.user_id),
role_id=str(role_id),
)
)
response = await client.delete(app.url_path_for("delete_user_group", user_group_id=group_id))
assert response.status_code == status.HTTP_204_NO_CONTENT
assert await rbac_repo.check_ace_exists(f"/access/groups/{group_id}") is False
class TestGroupMembersRoutes:
async def test_add_to_group_already_member(
@@ -14,14 +14,22 @@
# 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 json
import uuid
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from pydantic import ValidationError
from gns3server import schemas
from gns3server.api.routes.controller.controller import controller_ws_notifications
from gns3server.api.routes.controller.projects import (
project_marker_ws_notifications,
project_ws_notifications,
)
from gns3server.utils.notification_queue import NotificationQueue
STREAM_PATHS = ["/v3/notifications", "/v3/projects/{project_id}/notifications"]
@@ -86,3 +94,60 @@ class TestNotificationSchema:
notification = schemas.Notification.model_validate(message)
assert notification.action == schemas.NotificationAction.log_warning
assert notification.compute_id == "local"
def _fake_websocket(app: FastAPI) -> MagicMock:
websocket = MagicMock()
websocket.app = app
websocket.client = SimpleNamespace(host="127.0.0.1", port=1000)
websocket.send_text = AsyncMock()
return websocket
class TestNotificationWebSocketShutdown:
"""Notification WebSockets must stop when the server is shutting down.
Otherwise uvicorn has to wait for them during graceful shutdown.
"""
# Do not use monkeypatch.setattr on app.state: Starlette's State keeps
# attributes in an internal dict, not in the instance __dict__, so pytest
# 9.2+ cannot read back the old value and its teardown deletes the
# attribute for the rest of the test session.
@pytest.mark.asyncio
async def test_controller_ws_stops_when_app_is_exiting(self, app: FastAPI) -> None:
app.state.exiting = True
try:
websocket = _fake_websocket(app)
await asyncio.wait_for(controller_ws_notifications(websocket, current_user=MagicMock()), timeout=1)
websocket.send_text.assert_not_awaited()
finally:
app.state.exiting = False
@pytest.mark.asyncio
async def test_project_ws_stops_when_app_is_exiting(self, app: FastAPI, project) -> None:
project.auto_close = False
app.state.exiting = True
try:
websocket = _fake_websocket(app)
await asyncio.wait_for(project_ws_notifications(project.id, websocket, current_user=MagicMock()), timeout=1)
websocket.send_text.assert_not_awaited()
finally:
app.state.exiting = False
@pytest.mark.asyncio
async def test_marker_ws_stops_when_app_is_exiting(self, app: FastAPI, project) -> None:
app.state.exiting = True
try:
websocket = _fake_websocket(app)
await asyncio.wait_for(
project_marker_ws_notifications(project.id, websocket, current_user=MagicMock()), timeout=1
)
websocket.send_text.assert_not_awaited()
finally:
app.state.exiting = False
@@ -0,0 +1,191 @@
#
# 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/>.
#
"""
Tests for GET /projects/{project_id}/me/privileges: the per-project view of
the current user's effective privileges (unlike /access/users/me/privileges,
evaluated on the project resource path).
"""
import uuid
import pytest
import pytest_asyncio
from fastapi import FastAPI, status
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from gns3server.controller import Controller
from gns3server.controller.project import Project
from gns3server.db.models import User
from gns3server.db.repositories.pools import ResourcePoolsRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.schemas.controller.pools import ResourceCreate, ResourcePoolCreate
from gns3server.schemas.controller.rbac import ACECreate
from gns3server.services import auth_service
from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY
pytestmark = pytest.mark.asyncio
class TestProjectPrivilegesRoute:
"""
Every test creates its own project (unique id) and its own ACEs, so the
file is order-independent under --random-order.
"""
@pytest_asyncio.fixture
async def project(self, app: FastAPI, client: AsyncClient, controller: Controller) -> Project:
project_id = str(uuid.uuid4())
params = {"name": f"privileges-{project_id[:8]}", "project_id": project_id}
response = await client.post(app.url_path_for("create_project"), json=params)
assert response.status_code == status.HTTP_201_CREATED
return controller.get_project(project_id)
@staticmethod
def _user_headers(user: User) -> dict:
token = auth_service.create_access_token(user.username, secret_key=DEFAULT_JWT_SECRET_KEY)
return {"Authorization": f"Bearer {token}"}
async def _create_user_ace(self, db_session: AsyncSession, path: str, user: User, role_name: str) -> None:
rbac_repo = RbacRepository(db_session)
role = await rbac_repo.get_role_by_name(role_name)
await rbac_repo.create_ace(
ACECreate(
path=path,
ace_type="user",
propagate=True,
user_id=str(user.user_id),
role_id=str(role.role_id),
)
)
async def test_superadmin_gets_all_privileges(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
response = await client.get(app.url_path_for("get_logged_in_user_privileges_on_project", project_id=project.id))
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["is_superadmin"] is True
assert "Project.Audit" in data["privileges"]
assert "Project.Modify" in data["privileges"]
assert "Node.PowerMgmt" in data["privileges"]
async def test_unknown_project_returns_404(self, app: FastAPI, client: AsyncClient) -> None:
response = await client.get(
app.url_path_for("get_logged_in_user_privileges_on_project", project_id=str(uuid.uuid4()))
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_user_without_access_gets_403(
self, app: FastAPI, client: AsyncClient, project: Project, test_user: User
) -> None:
response = await client.get(
app.url_path_for("get_logged_in_user_privileges_on_project", project_id=project.id),
headers=self._user_headers(test_user),
)
assert response.status_code == status.HTTP_403_FORBIDDEN
async def test_user_ace_grants_project_scoped_privileges(
self,
app: FastAPI,
client: AsyncClient,
project: Project,
test_user: User,
db_session: AsyncSession,
) -> None:
# the "User" role carries the whole project-subtree family
await self._create_user_ace(db_session, f"/projects/{project.id}", test_user, "User")
response = await client.get(
app.url_path_for("get_logged_in_user_privileges_on_project", project_id=project.id),
headers=self._user_headers(test_user),
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["is_superadmin"] is False
for privilege in (
"Project.Audit",
"Project.Modify",
"Node.Modify",
"Node.Console",
"Node.PowerMgmt",
"Link.Capture",
"Snapshot.Restore",
):
assert privilege in data["privileges"]
# privileges outside the role must not appear, even path-scoped
assert "User.Audit" not in data["privileges"]
assert "Template.Modify" not in data["privileges"]
async def test_auditor_role_gets_audit_only(
self,
app: FastAPI,
client: AsyncClient,
project: Project,
test_user: User,
db_session: AsyncSession,
) -> None:
await self._create_user_ace(db_session, f"/projects/{project.id}", test_user, "Auditor")
response = await client.get(
app.url_path_for("get_logged_in_user_privileges_on_project", project_id=project.id),
headers=self._user_headers(test_user),
)
assert response.status_code == status.HTTP_200_OK
privileges = response.json()["privileges"]
assert "Project.Audit" in privileges
assert "Node.Audit" in privileges
assert "Link.Audit" in privileges
# auditors can look but not touch
assert "Project.Modify" not in privileges
assert "Node.Modify" not in privileges
assert "Node.Console" not in privileges
assert "Snapshot.Restore" not in privileges
async def test_pool_membership_grants_privileges(
self,
app: FastAPI,
client: AsyncClient,
controller: Controller,
project: Project,
test_user: User,
db_session: AsyncSession,
) -> None:
pools_repo = ResourcePoolsRepository(db_session)
pool = await pools_repo.create_resource_pool(ResourcePoolCreate(name=f"pool-{uuid.uuid4().hex[:8]}"))
resource = await pools_repo.create_resource(
ResourceCreate(resource_id=uuid.UUID(project.id), resource_type="project", name=project.name)
)
await pools_repo.add_resource_to_pool(pool.resource_pool_id, resource)
await self._create_user_ace(db_session, f"/pools/{pool.resource_pool_id}", test_user, "Auditor")
response = await client.get(
app.url_path_for("get_logged_in_user_privileges_on_project", project_id=project.id),
headers=self._user_headers(test_user),
)
assert response.status_code == status.HTTP_200_OK
privileges = response.json()["privileges"]
assert "Project.Audit" in privileges
assert "Project.Modify" not in privileges
@@ -526,6 +526,8 @@ class TestControllerProjectRoutes:
with open(os.path.join(project.path, "demo")) as f:
content = f.read()
assert content == "hello"
# the imported project is owned by the user who imported it
assert project.created_by == "admin"
async def test_import_with_project_name(
self, app: FastAPI, client: AsyncClient, tmpdir, controller: Controller
@@ -556,6 +558,8 @@ class TestControllerProjectRoutes:
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["name"] == "hello"
# the duplicated project is owned by the user who duplicated it
assert response.json()["created_by"] == "admin"
async def test_lock_unlock(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute) -> None:
+174 -1
View File
@@ -27,9 +27,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
import gns3server.db.models as models
from gns3server.config import Config
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.db.repositories.users import UsersRepository
from gns3server.schemas.controller.users import User
from gns3server.schemas.controller.rbac import ACECreate
from gns3server.schemas.controller.users import User, UserCreate, UserGroupCreate
from gns3server.services import auth_service
from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY
pytestmark = pytest.mark.asyncio
@@ -147,6 +150,36 @@ class TestUserRoutes:
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) == 4 # admin, user1, user2 and user3 should exist
async def test_delete_user_removes_ace_on_user_path(
self, app: FastAPI, client: AsyncClient, db_session: AsyncSession
) -> None:
"""
A group ACE on the user path is not covered by the user_id foreign key
cascade: deleting the user must remove it by path.
"""
new_user = {"username": "ace_cleanup_user", "email": "ace_cleanup_user@email.com", "password": "test_password"}
response = await client.post(app.url_path_for("create_user"), json=new_user)
assert response.status_code == status.HTTP_201_CREATED
user_id = response.json()["user_id"]
group_id = (await UsersRepository(db_session).get_user_group_by_name("Users")).user_group_id
role_id = (await RbacRepository(db_session).get_role_by_name("User")).role_id
rbac_repo = RbacRepository(db_session)
await rbac_repo.create_ace(
ACECreate(
path=f"/access/users/{user_id}",
ace_type="group",
propagate=False,
group_id=str(group_id),
role_id=str(role_id),
)
)
response = await client.delete(app.url_path_for("delete_user", user_id=user_id))
assert response.status_code == status.HTTP_204_NO_CONTENT
assert await rbac_repo.check_ace_exists(f"/access/users/{user_id}") is False
class TestAuthTokens:
async def test_can_create_token_successfully(
@@ -711,3 +744,143 @@ class TestSuperAdmin:
}
response = await unauthorized_client.post(app.url_path_for("login"), data=login_data)
assert response.status_code == status.HTTP_200_OK
class TestUserPrivileges:
"""
Tests for GET /access/users/me/privileges.
Every test creates its own users, groups and ACEs: no shared rows with
other tests in this file, and authentication is sent per-request so the
class stays order-independent.
"""
async def _create_user(self, db_session: AsyncSession, username: str) -> models.User:
return await UsersRepository(db_session).create_user(
UserCreate(username=username, email=f"{username}@email.com", password="secret_password")
)
async def _create_group_with_member(
self, db_session: AsyncSession, name: str, user: models.User
) -> models.UserGroup:
users_repo = UsersRepository(db_session)
group = await users_repo.create_user_group(UserGroupCreate(name=name))
await users_repo.add_member_to_user_group(group.user_group_id, user)
return group
@staticmethod
def _headers(username: str) -> dict:
token = auth_service.create_access_token(username, secret_key=DEFAULT_JWT_SECRET_KEY)
return {"Authorization": f"Bearer {token}"}
async def test_superadmin_gets_all_privileges(
self, app: FastAPI, client: AsyncClient, db_session: AsyncSession
) -> None:
rbac_repo = RbacRepository(db_session)
expected = sorted(privilege.name for privilege in await rbac_repo.get_privileges())
response = await client.get(app.url_path_for("get_logged_in_user_privileges"), headers=self._headers("admin"))
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["is_superadmin"] is True
assert data["privileges"] == expected
async def test_user_without_ace_has_no_privileges(
self, app: FastAPI, client: AsyncClient, db_session: AsyncSession
) -> None:
user = await self._create_user(db_session, "priv_no_ace")
response = await client.get(
app.url_path_for("get_logged_in_user_privileges"), headers=self._headers(user.username)
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["is_superadmin"] is False
assert data["privileges"] == []
async def test_privileges_from_user_ace(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None:
user = await self._create_user(db_session, "priv_user_ace")
rbac_repo = RbacRepository(db_session)
role = await rbac_repo.get_role_by_name("Administrator")
await rbac_repo.create_ace(
ACECreate(path="/projects", ace_type="user", user_id=user.user_id, role_id=role.role_id)
)
expected = sorted(privilege.name for privilege in await rbac_repo.get_role_privileges(role.role_id))
response = await client.get(
app.url_path_for("get_logged_in_user_privileges"), headers=self._headers(user.username)
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["privileges"] == expected
async def test_privileges_from_group_ace(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None:
user = await self._create_user(db_session, "priv_group_ace")
group = await self._create_group_with_member(db_session, "priv_group", user)
rbac_repo = RbacRepository(db_session)
role = await rbac_repo.get_role_by_name("Auditor")
await rbac_repo.create_ace(
ACECreate(path="/projects", ace_type="group", group_id=group.user_group_id, role_id=role.role_id)
)
expected = sorted(privilege.name for privilege in await rbac_repo.get_role_privileges(role.role_id))
response = await client.get(
app.url_path_for("get_logged_in_user_privileges"), headers=self._headers(user.username)
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["privileges"] == expected
async def test_privileges_are_the_union_of_user_and_group_aces(
self, app: FastAPI, client: AsyncClient, db_session: AsyncSession
) -> None:
user = await self._create_user(db_session, "priv_union")
group = await self._create_group_with_member(db_session, "priv_union_group", user)
rbac_repo = RbacRepository(db_session)
user_role = await rbac_repo.get_role_by_name("Auditor")
group_role = await rbac_repo.get_role_by_name("User")
await rbac_repo.create_ace(
ACECreate(path="/projects", ace_type="user", user_id=user.user_id, role_id=user_role.role_id)
)
await rbac_repo.create_ace(
ACECreate(path="/templates", ace_type="group", group_id=group.user_group_id, role_id=group_role.role_id)
)
expected = set(privilege.name for privilege in await rbac_repo.get_role_privileges(user_role.role_id))
expected.update(privilege.name for privilege in await rbac_repo.get_role_privileges(group_role.role_id))
response = await client.get(
app.url_path_for("get_logged_in_user_privileges"), headers=self._headers(user.username)
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["is_superadmin"] is False
assert data["privileges"] == sorted(expected)
async def test_denial_ace_is_not_reported_as_a_privilege(
self, app: FastAPI, client: AsyncClient, db_session: AsyncSession
) -> None:
user = await self._create_user(db_session, "priv_denied")
rbac_repo = RbacRepository(db_session)
role = await rbac_repo.get_role_by_name("Administrator")
await rbac_repo.create_ace(
ACECreate(path="/projects", ace_type="user", user_id=user.user_id, role_id=role.role_id, allowed=False)
)
response = await client.get(
app.url_path_for("get_logged_in_user_privileges"), headers=self._headers(user.username)
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["privileges"] == []
async def test_privileges_require_authentication(self, app: FastAPI, client: AsyncClient) -> None:
response = await client.get(
app.url_path_for("get_logged_in_user_privileges"), headers={"Authorization": "Bearer invalid_token"}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
+282 -9
View File
@@ -16,13 +16,20 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
import uuid
from unittest.mock import MagicMock, patch
import pytest
import pytest_asyncio
from gns3server.compute.docker import DOCKER_MINIMUM_API_VERSION, DOCKER_PREFERRED_API_VERSION, Docker
from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error
from gns3server.compute.docker import (
DOCKER_MINIMUM_API_VERSION,
DOCKER_PREFERRED_API_VERSION,
Docker,
)
from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error, DockerHttp409Error
from gns3server.compute.docker.docker_images import PULL_IDLE_TIMEOUT
from gns3server.compute.project_manager import ProjectManager
from tests.utils import AsyncioMagicMock, asyncio_patch
@@ -132,11 +139,64 @@ async def test_list_images():
images = await Docker.instance().list_images()
mock.assert_called_with("GET", "images/json", params={"all": 0})
assert len(images) == 5
assert {"image": "ubuntu:12.04"} in images
assert {"image": "ubuntu:precise"} in images
assert {"image": "ubuntu:latest"} in images
assert {"image": "ubuntu:12.10"} in images
assert {"image": "ubuntu:quantal"} in images
by_tag = {image["image"]: image for image in images}
assert by_tag["ubuntu:12.04"]["id"] == "8dbd9e392a964056420e5d58ca5cc376ef18e2de93b5cc90e868a1bbc8318c1c"
assert by_tag["ubuntu:12.04"]["size"] == 131506275
assert by_tag["ubuntu:12.04"]["created"] == 1365714795
# tags of the same image share its id
assert by_tag["ubuntu:precise"]["id"] == by_tag["ubuntu:12.04"]["id"]
assert "<none>:<none>" not in by_tag
assert {"ubuntu:precise", "ubuntu:latest", "ubuntu:12.10", "ubuntu:quantal"} <= set(by_tag)
@pytest.mark.asyncio
async def test_list_images_created_fallback_to_oci_label():
"""
Images built without a created timestamp (e.g. reproducible Ubuntu images)
report 0 from the daemon: the build time comes from the OCI created label.
"""
response = [
{
# zero timestamp, the label is used (Z suffix, nanosecond precision)
"RepoTags": ["ubuntu:latest"],
"Id": "febc1a2eec1f",
"Created": 0,
"Size": 78101592,
"Labels": {"org.opencontainers.image.created": "2026-10-25T10:26:07.538865123Z"},
},
{
# zero timestamp without a label stays at zero
"RepoTags": ["no-label:latest"],
"Id": "aaaaaa",
"Created": 0,
"Size": 1,
},
{
# the daemon timestamp wins over the label when it is set
"RepoTags": ["normal:latest"],
"Id": "bbbbbb",
"Created": 1365714795,
"Size": 1,
"Labels": {"org.opencontainers.image.created": "2000-01-01T00:00:00Z"},
},
{
# a broken label is ignored, the timestamp stays at zero
"RepoTags": ["broken-label:latest"],
"Id": "cccccc",
"Created": 0,
"Size": 1,
"Labels": {"org.opencontainers.image.created": "not a date"},
},
]
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response):
images = await Docker.instance().list_images()
by_tag = {image["image"]: image for image in images}
assert by_tag["ubuntu:latest"]["created"] == 1792923967
assert by_tag["no-label:latest"]["created"] == 0
assert by_tag["normal:latest"]["created"] == 1365714795
assert by_tag["broken-label:latest"]["created"] == 0
@pytest.mark.asyncio
@@ -165,7 +225,50 @@ async def test_pull_image():
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=mock_query) as mock:
await Docker.instance().pull_image("ubuntu")
mock.assert_called_with("POST", "images/create", params={"fromImage": "ubuntu"}, timeout=None)
args, kwargs = mock.call_args
assert args == ("POST", "images/create")
assert kwargs["params"] == {"fromImage": "ubuntu", "tag": "latest"}
# no total timeout (a pull can take as long as it needs) but an
# idle socket read timeout, so an unreachable registry fails
assert kwargs["timeout"].total is None
assert kwargs["timeout"].sock_read == PULL_IDLE_TIMEOUT
@pytest.mark.asyncio
async def test_pull_image_reports_aggregated_progress():
class Content:
def __init__(self):
self._chunks = [
b'{"status": "Pulling fs layer", "id": "sha256:a"}\n'
b'{"status": "Pulling fs layer", "id": "sha256:b"}\n'
b'{"status": "Downloading", "id": "sha256:a", "progress": "[==> ] 40/100"}\n'
b'{"status": "Pull complete", "id": "sha256:a"}\n',
b'{"status": "Pull complete", "id": "sha256:b"}\n',
b"",
]
async def read(self, size):
return self._chunks.pop(0)
response = MagicMock()
response.content = Content()
progress = []
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
await Docker.instance().pull_image("ubuntu", progress_callback=progress.append)
# the callback sees the running totals of layers, not raw daemon lines
assert progress[0] == {
"layers_total": 0,
"layers_done": 0,
"message": "Pulling 'ubuntu' from Docker repository",
"state": "started",
}
assert progress[1] == {"layers_total": 1, "layers_done": 0, "message": "Pulling fs layer"}
assert progress[2] == {"layers_total": 2, "layers_done": 0, "message": "Pulling fs layer"}
assert {"layers_total": 2, "layers_done": 0, "message": "[==> ] 40/100"} in progress
assert progress[-1] == {"layers_total": 2, "layers_done": 2, "message": "Pull complete"}
@pytest.mark.asyncio
@@ -188,7 +291,40 @@ async def test_force_pull_image():
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response) as pull_mock:
await Docker.instance().pull_image("ubuntu", force=True)
query_mock.assert_not_called()
pull_mock.assert_called_with("POST", "images/create", params={"fromImage": "ubuntu"}, timeout=None)
args, kwargs = pull_mock.call_args
assert args == ("POST", "images/create")
assert kwargs["params"] == {"fromImage": "ubuntu", "tag": "latest"}
assert kwargs["timeout"].sock_read == PULL_IDLE_TIMEOUT
@pytest.mark.asyncio
@pytest.mark.parametrize(
"image, expected_params",
[
# without a tag or digest the daemon would pull every tag of the
# repository: default to latest so the pull targets a single tag
("ubuntu", {"fromImage": "ubuntu", "tag": "latest"}),
("gns3/ubuntu", {"fromImage": "gns3/ubuntu", "tag": "latest"}),
# the ':' before the last '/' is a registry port, not a tag
("registry:5000/ubuntu", {"fromImage": "registry:5000/ubuntu", "tag": "latest"}),
("registry:5000/gns3/ubuntu", {"fromImage": "registry:5000/gns3/ubuntu", "tag": "latest"}),
# an explicit tag or digest is left alone
("ubuntu:24.04", {"fromImage": "ubuntu:24.04"}),
("registry:5000/ubuntu:24.04", {"fromImage": "registry:5000/ubuntu:24.04"}),
("ubuntu@sha256:8b2f", {"fromImage": "ubuntu@sha256:8b2f"}),
],
)
async def test_pull_image_targets_a_single_tag(image, expected_params):
response = MagicMock()
response.content.read = AsyncioMagicMock(return_value=b"")
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response) as pull_mock:
await Docker.instance().pull_image(image)
args, kwargs = pull_mock.call_args
assert args == ("POST", "images/create")
assert kwargs["params"] == expected_params
@pytest.mark.asyncio
@@ -211,6 +347,24 @@ async def test_pull_image_error():
response.close.assert_called_once()
@pytest.mark.asyncio
async def test_pull_image_not_found_is_not_reported_as_a_network_error():
"""
A 404 from the registry means the image name is wrong or the registry needs
authentication: telling the user to check the Internet connection sends
them in the wrong direction.
"""
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch(
"gns3server.compute.docker.Docker.http_query",
side_effect=DockerHttp404Error("Docker has returned an error: 404 pull access denied for debian13"),
):
with pytest.raises(DockerError, match="not found in the registry") as error:
await Docker.instance().pull_image("debian13")
assert "Internet connection" not in str(error.value)
@pytest.mark.asyncio
async def test_pull_image_rejects_incomplete_response():
@@ -330,6 +484,125 @@ async def test_load_image_error():
response.close.assert_called_once()
@pytest.mark.asyncio
async def test_delete_image():
docker = Docker.instance()
docker._image_id_translations["ubuntu:latest"] = ("sha256:" + "a" * 64, "sha256:" + "b" * 64)
with asyncio_patch("gns3server.compute.docker.Docker.query") as mock:
await docker.delete_image("ubuntu:latest", force=True)
# the daemon query string only accepts strings, ints and floats: a
# Python bool would make the aiohttp URL builder raise a TypeError
mock.assert_called_with("DELETE", "images/ubuntu:latest", params={"force": "true"})
# deleting the image drops the id translation recorded for a previous sync
assert docker.image_id_translation("ubuntu:latest") is None
@pytest.mark.asyncio
async def test_delete_image_error_keeps_translation():
"""
When the daemon refuses the deletion the image is still there, so the id
translation recorded for a previous sync stays valid.
"""
docker = Docker.instance()
translation = ("sha256:" + "a" * 64, "sha256:" + "b" * 64)
docker._image_id_translations["ubuntu:latest"] = translation
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerError("conflict: image is in use")):
with pytest.raises(DockerError, match="image is in use"):
await docker.delete_image("ubuntu:latest")
assert docker.image_id_translation("ubuntu:latest") == translation
DAEMON_DELETE_CONFLICT = (
"Docker has returned an error: 409 conflict: unable to remove repository reference "
'"alpine:3" (must force) - container 6d59f00236ff is using its referenced image d529dd0c6e55'
)
@pytest.mark.asyncio
async def test_delete_image_conflict_names_the_node_and_project():
"""
A container blocking the deletion is reported as the GNS3 node and project
it belongs to, not as a raw container id.
"""
docker = Docker.instance()
project_id = str(uuid.uuid4())
project = ProjectManager.instance().create_project(name="test-lab", project_id=project_id)
async def query(method, path, **kwargs):
assert path.startswith("images/") or path.startswith("containers/")
if method == "DELETE":
raise DockerHttp409Error(DAEMON_DELETE_CONFLICT)
return {"Name": f"/GNS3.alpine-1.{project_id}", "State": {"Running": True}}
try:
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=query):
with pytest.raises(DockerHttp409Error) as excinfo:
await docker.delete_image("alpine:3")
message = str(excinfo.value)
assert "node 'alpine-1' in project 'test-lab' (running)" in message
assert "force=true" in message
assert "6d59f00236ff" not in message
finally:
ProjectManager.instance().remove_project(project.id)
@pytest.mark.asyncio
async def test_delete_image_conflict_reports_a_leftover_container():
"""
When the project of the blocking container is closed on this compute the
container is reported as a leftover.
"""
docker = Docker.instance()
async def query(method, path, **kwargs):
if method == "DELETE":
raise DockerHttp409Error(DAEMON_DELETE_CONFLICT)
return {"Name": f"/GNS3.E2E-2.{uuid.uuid4()}", "State": {"Running": False}}
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=query):
with pytest.raises(DockerHttp409Error, match="leftover container of node 'E2E-2' \\(stopped"):
await docker.delete_image("alpine:3")
@pytest.mark.asyncio
async def test_delete_image_conflict_reports_a_non_gns3_container():
"""
A container created outside GNS3 is reported by its plain container name.
"""
docker = Docker.instance()
async def query(method, path, **kwargs):
if method == "DELETE":
raise DockerHttp409Error(DAEMON_DELETE_CONFLICT)
return {"Name": "/my-own-container", "State": {"Running": True}}
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=query):
with pytest.raises(DockerHttp409Error, match="container 'my-own-container' \\(running\\)"):
await docker.delete_image("alpine:3")
@pytest.mark.asyncio
async def test_delete_image_conflict_without_containers_is_not_rewritten():
"""
A daemon conflict naming no container (e.g. a multi repository reference)
is passed through unchanged.
"""
docker = Docker.instance()
original = "Docker has returned an error: 409 conflict: image is referenced in multiple repositories"
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp409Error(original)):
with pytest.raises(DockerHttp409Error, match="multiple repositories"):
await docker.delete_image("alpine:3")
@pytest.mark.asyncio
async def test_docker_check_connection_docker_minimum_version(vm):
+5
View File
@@ -27,6 +27,7 @@ from gns3server.compute.project_manager import ProjectManager
from gns3server.config import Config
from gns3server.controller import Controller
from gns3server.db.models import Base, Compute, User
from gns3server.db.rbac_state import RbacState
from gns3server.db.repositories.computes import ComputesRepository
from gns3server.db.repositories.users import UsersRepository
from gns3server.schemas.controller.computes import Protocol
@@ -82,6 +83,10 @@ async def db_session(db_engine):
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
# the RBAC snapshot is process-wide: drop it so it reloads from the
# freshly recreated tables instead of holding data from a previous class
RbacState.instance().reset()
session = AsyncSession(db_engine, expire_on_commit=False)
try:
yield session
+4 -1
View File
@@ -640,7 +640,10 @@ async def test_connect_notification_poison_frame_autoreconnects(compute, monkeyp
# would execute module-level from-imports while the autouse fixture's
# monkeypatches are active, freezing patched objects into namespaces)
monkeypatch.delattr(sys, "_called_from_test", raising=False)
monkeypatch.setattr(gns3_app.state, "exiting", False)
# plain assignment instead of monkeypatch.setattr: Starlette's State keeps
# attributes in an internal dict, so pytest 9.2+ teardown would delete the
# attribute for the rest of the test session
gns3_app.state.exiting = False
async def fake_connect():
compute._reconnect_attempted = True
+28
View File
@@ -119,6 +119,34 @@ async def test_dispatch_node_updated(controller, node, project):
assert event["properties"]["startup_config"] == "ip 192"
@pytest.mark.asyncio
async def test_dispatch_docker_image_routed_to_controller_stream(controller, project):
"""
docker.image.* events are scoped to a compute, not to a project: they must
reach the controller stream even when no project is open, and not the
project stream.
"""
notif = controller.notification
with notif.controller_queue() as controller_q, notif.project_queue(project.id) as project_q:
await controller_q.get(0.1) # ping
await project_q.get(0.1) # ping
await notif.dispatch(
"docker.image.pull", {"image": "debian", "state": "started"}, project_id=None, compute_id="local"
)
msg = await controller_q.get(5)
assert msg == (
"docker.image.pull",
{"image": "debian", "state": "started", "compute_id": "local"},
{},
)
# the project stream only carries its periodic ping
msg = await project_q.get(0.2)
assert msg[0] == "ping"
@pytest.mark.asyncio
async def test_dispatch_marker_routed_to_marker_channel(controller, project):
"""
+21 -1
View File
@@ -936,9 +936,10 @@ async def test_duplicate(project, controller):
compute, "test", None, node_type="vmware", properties={"startup_config": "test.cfg"}
)
new_project = await project.duplicate(name="Hello")
new_project = await project.duplicate(name="Hello", user="user1")
assert new_project.id != project.id
assert new_project.name == "Hello"
assert new_project.created_by == "user1"
await new_project.open()
@@ -946,6 +947,25 @@ async def test_duplicate(project, controller):
assert list(new_project.nodes.values())[1].compute.id == "remote"
@pytest.mark.asyncio
async def test_duplicate_created_by(project, controller):
"""
The duplicated project is owned by the user who duplicated it (fast path)
"""
project.created_by = "alice"
project.dump()
new_project = await project.duplicate(name="Hello", user="bob")
assert new_project.created_by == "bob"
# the original project keeps its owner
assert project.created_by == "alice"
# without a user the original owner is kept (backward compatible)
new_project = await project.duplicate(name="Hello2")
assert new_project.created_by == "alice"
def test_snapshots(project):
"""
List the snapshots
+102 -10
View File
@@ -29,7 +29,7 @@ from gns3server.db.repositories.pools import ResourcePoolsRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.db.repositories.users import UsersRepository
from gns3server.schemas.controller.pools import ResourceCreate, ResourcePoolCreate
from gns3server.schemas.controller.rbac import ACECreate
from gns3server.schemas.controller.rbac import ACECreate, ACEUpdate
from gns3server.services import auth_service
pytestmark = pytest.mark.asyncio
@@ -80,26 +80,57 @@ class TestPrivileges:
authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
assert authorized == result
async def _get_or_create_projects_ace(
self, db_session: AsyncSession, *, propagate: bool, allowed: bool = True
) -> None:
"""
The tests must not depend on the /projects ACE created by another test:
reuse it when present, otherwise create it with the requested flags.
"""
rbac_repo = RbacRepository(db_session)
ace = await rbac_repo.get_ace_by_path("/projects")
if ace is None:
group_id = (await UsersRepository(db_session).get_user_group_by_name("Users")).user_group_id
role_id = (await rbac_repo.get_role_by_name("User")).role_id
await rbac_repo.create_ace(
ACECreate(
path="/projects",
ace_type="group",
propagate=propagate,
allowed=allowed,
group_id=str(group_id),
role_id=str(role_id),
)
)
else:
await rbac_repo.update_ace(
ace.ace_id,
ACEUpdate(
ace_type=ace.ace_type,
path=ace.path,
role_id=ace.role_id,
propagate=propagate,
allowed=allowed,
),
)
async def test_propagate(self, test_user: User, db_session: AsyncSession):
privilege = "Project.Audit"
path = "/projects/44929147-47bb-460a-90ae-c782c4dbb6ef"
authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
rbac_repo = RbacRepository(db_session)
authorized = await rbac_repo.check_user_has_privilege(test_user.user_id, path, privilege)
assert authorized is False
ace = await RbacRepository(db_session).get_ace_by_path("/projects")
ace.propagate = True
await db_session.commit()
await self._get_or_create_projects_ace(db_session, propagate=True)
authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
authorized = await rbac_repo.check_user_has_privilege(test_user.user_id, path, privilege)
assert authorized is True
async def test_allowed(self, test_user: User, db_session: AsyncSession):
ace = await RbacRepository(db_session).get_ace_by_path("/projects")
ace.allowed = False
ace.propagate = True
await db_session.commit()
await self._get_or_create_projects_ace(db_session, propagate=True, allowed=False)
privilege = "Project.Audit"
path = "/projects/44929147-47bb-460a-90ae-c782c4dbb6ef"
@@ -115,6 +146,27 @@ class TestPrivileges:
authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
assert authorized is True
async def test_delete_all_ace_starting_with_path_is_committed(self, db_session: AsyncSession):
"""
Production requests get a fresh session that is only closed at
teardown, rolling back uncommitted work: the bulk ACE deletion must
be committed to survive the end of the request.
"""
rbac_repo = RbacRepository(db_session)
group_id = (await UsersRepository(db_session).get_user_group_by_name("Users")).user_group_id
role_id = (await rbac_repo.get_role_by_name("User")).role_id
path = f"/projects/{uuid.uuid4()}"
await rbac_repo.create_ace(
ACECreate(path=path, ace_type="group", propagate=False, group_id=str(group_id), role_id=str(role_id))
)
await rbac_repo.delete_all_ace_starting_with_path(path)
# simulate the production session teardown: close() rolls back the
# open transaction and an uncommitted deletion would be lost
await db_session.rollback()
assert await rbac_repo.check_ace_exists(path) is False
class TestResourcePools:
async def test_resource_pool(self, test_user: User, db_session: AsyncSession):
@@ -221,6 +273,46 @@ class TestResourcePools:
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) == 3
async def test_list_projects_legacy_without_created_by(
self, app: FastAPI, controller: Controller, base_client: AsyncClient, db_session: AsyncSession, test_user: User
) -> None:
"""
Projects created before GNS3 3.1 have no created_by field. They should
remain visible through a direct ACE (the pre-3.1 visibility rule)
instead of being hidden from every non-superadmin user.
"""
# Clean up any existing ACEs from previous tests
await RbacRepository(db_session).delete_all_ace_starting_with_path("/projects")
await RbacRepository(db_session).delete_all_ace_starting_with_path("/pools")
legacy_id = str(uuid.uuid4()) # created with GNS3 < 3.1, no created_by
owned_id = str(uuid.uuid4())
other_id = str(uuid.uuid4())
await controller.add_project(project_id=legacy_id, name="LegacyProject")
await controller.add_project(project_id=owned_id, name="OwnedProject", created_by=test_user.username)
await controller.add_project(project_id=other_id, name="OtherProject", created_by="admin")
group_id = (await UsersRepository(db_session).get_user_group_by_name("Users")).user_group_id
role_id = (await RbacRepository(db_session).get_role_by_name("User")).role_id
ace = ACECreate(
path="/projects", ace_type="group", propagate=True, group_id=str(group_id), role_id=str(role_id)
)
await RbacRepository(db_session).create_ace(ace)
access_token = auth_service.create_access_token(test_user.username)
async with AsyncClient(
base_url="http://test-api",
headers={"Content-Type": "application/json", "Authorization": f"Bearer {access_token}"},
transport=ASGIWebSocketTransport(app=app),
) as user_client:
response = await user_client.get(app.url_path_for("get_projects"))
assert response.status_code == status.HTTP_200_OK
projects = response.json()
# the legacy project (no owner) and the user's own project are visible,
# but the project owned by another user is not
assert {p["name"] for p in projects} == {"LegacyProject", "OwnedProject"}
# class TestProjectsWithRbac:
#
+209
View File
@@ -0,0 +1,209 @@
#
# 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/>.
#
"""
Tests for the in-memory RBAC snapshot: every write through a repository
must be visible to the next permission check (write-through), and the
snapshot must survive a reset (database recreated).
"""
import uuid
import pytest
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import AsyncSession
from gns3server.db.models import User
from gns3server.db.rbac_state import RbacState
from gns3server.db.repositories.pools import ResourcePoolsRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.db.repositories.users import UsersRepository
from gns3server.schemas.controller.pools import ResourceCreate, ResourcePoolCreate
from gns3server.schemas.controller.rbac import ACECreate, RoleCreate
from gns3server.schemas.controller.users import UserGroupCreate
pytestmark = pytest.mark.asyncio
class TestRbacStateWriteThrough:
"""
Each test builds its own rows (unique paths and names) so the file is
order-independent under --random-order.
"""
async def test_group_membership_change_is_visible(self, test_user: User, db_session: AsyncSession) -> None:
path = f"/projects/{uuid.uuid4()}"
rbac_repo = RbacRepository(db_session)
users_repo = UsersRepository(db_session)
group = await users_repo.create_user_group(UserGroupCreate(name=f"group-{uuid.uuid4().hex[:8]}"))
role = await rbac_repo.get_role_by_name("User")
await rbac_repo.create_ace(
ACECreate(
path=path,
ace_type="group",
propagate=False,
group_id=str(group.user_group_id),
role_id=str(role.role_id),
)
)
# the user is not a member of the group yet
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is False
await users_repo.add_member_to_user_group(group.user_group_id, test_user)
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is True
await users_repo.remove_member_from_user_group(group.user_group_id, test_user)
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is False
async def test_role_privilege_change_is_visible(self, test_user: User, db_session: AsyncSession) -> None:
path = f"/projects/{uuid.uuid4()}"
rbac_repo = RbacRepository(db_session)
users_repo = UsersRepository(db_session)
# a role without any privilege
role = await rbac_repo.create_role(RoleCreate(name=f"role-{uuid.uuid4().hex[:8]}"))
group_id = (await users_repo.get_user_group_by_name("Users")).user_group_id
await rbac_repo.create_ace(
ACECreate(path=path, ace_type="group", propagate=False, group_id=str(group_id), role_id=str(role.role_id))
)
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is False
privilege = await rbac_repo.get_privilege_by_name("Project.Audit")
await rbac_repo.add_privilege_to_role(role.role_id, privilege)
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is True
async def test_pool_membership_change_is_visible(self, test_user: User, db_session: AsyncSession) -> None:
project_id = str(uuid.uuid4())
rbac_repo = RbacRepository(db_session)
users_repo = UsersRepository(db_session)
pools_repo = ResourcePoolsRepository(db_session)
pool = await pools_repo.create_resource_pool(ResourcePoolCreate(name=f"pool-{uuid.uuid4().hex[:8]}"))
resource = await pools_repo.create_resource(
ResourceCreate(resource_id=uuid.UUID(project_id), resource_type="project", name=f"project-{project_id[:8]}")
)
group_id = (await users_repo.get_user_group_by_name("Users")).user_group_id
role = await rbac_repo.get_role_by_name("User")
await rbac_repo.create_ace(
ACECreate(
path=f"/pools/{pool.resource_pool_id}",
ace_type="group",
propagate=False,
group_id=str(group_id),
role_id=str(role.role_id),
)
)
# the resource is not in the pool yet
assert (
await rbac_repo.check_user_has_privilege(test_user.user_id, f"/projects/{project_id}", "Project.Audit")
is False
)
await pools_repo.add_resource_to_pool(pool.resource_pool_id, resource)
assert (
await rbac_repo.check_user_has_privilege(test_user.user_id, f"/projects/{project_id}", "Project.Audit")
is True
)
async def test_user_ace_denies_group_ace(self, test_user: User, db_session: AsyncSession) -> None:
path = f"/projects/{uuid.uuid4()}"
rbac_repo = RbacRepository(db_session)
users_repo = UsersRepository(db_session)
group_id = (await users_repo.get_user_group_by_name("Users")).user_group_id
role = await rbac_repo.get_role_by_name("User")
await rbac_repo.create_ace(
ACECreate(path=path, ace_type="group", propagate=False, group_id=str(group_id), role_id=str(role.role_id))
)
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is True
# a deny ACE on the user itself replaces the group privileges
await rbac_repo.create_ace(
ACECreate(
path=path,
ace_type="user",
propagate=False,
allowed=False,
user_id=str(test_user.user_id),
role_id=str(role.role_id),
)
)
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is False
class TestRbacStateReload:
async def test_dirty_flag_cycle(self, test_user: User, db_session: AsyncSession) -> None:
state = RbacState.instance()
state.reset()
rbac_repo = RbacRepository(db_session)
users_repo = UsersRepository(db_session)
path = f"/projects/{uuid.uuid4()}"
await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit")
assert state._loaded is True
assert state._dirty is False
group_id = (await users_repo.get_user_group_by_name("Users")).user_group_id
role = await rbac_repo.get_role_by_name("User")
await rbac_repo.create_ace(
ACECreate(path=path, ace_type="group", propagate=False, group_id=str(group_id), role_id=str(role.role_id))
)
assert state._dirty is True
# the next check reloads the snapshot and clears the flag
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is True
assert state._dirty is False
async def test_reset_forces_reload_from_database(self, test_user: User, db_session: AsyncSession) -> None:
rbac_repo = RbacRepository(db_session)
users_repo = UsersRepository(db_session)
path = f"/projects/{uuid.uuid4()}"
group_id = (await users_repo.get_user_group_by_name("Users")).user_group_id
role = await rbac_repo.get_role_by_name("User")
await rbac_repo.create_ace(
ACECreate(path=path, ace_type="group", propagate=False, group_id=str(group_id), role_id=str(role.role_id))
)
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is True
# a reset drops the in-memory data; the next check reloads from the
# database and the ACE is still there
RbacState.instance().reset()
assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Audit") is True
async def test_startup_helper_loads_snapshot(
self, app: FastAPI, db_engine, test_user: User, db_session: AsyncSession
) -> None:
from gns3server.db.tasks import load_rbac_state
RbacState.instance().reset()
app.state._db_engine = db_engine
await load_rbac_state(app)
assert RbacState.instance()._loaded is True
assert RbacState.instance()._dirty is False
@@ -222,6 +222,22 @@ async def test_external_symlinks_hidden_files_and_libraries_are_not_imported(inv
assert outside.read_bytes() == QCOW
async def test_non_image_files_next_to_images_are_ignored(inventory, config):
"""Files a user keeps next to images (scripts, notes, licenses) must not be
reported as invalid images nor turn the sync job partial."""
image_file(config, "IOU/CiscoIOUKeygen.py", b"#!/usr/bin/env python\ndef main(): ...")
image_file(config, "IOU/README.md", b"# IOU images")
image_file(config, "QEMU/notes.TXT", b"my qemu notes")
image_file(config, "IOS/license.lic", b"license text")
result = await scan(inventory)
assert result["counts"]["scanned"] == 0
assert result["counts"]["invalid"] == 0
assert result["counts"]["errors"] == 0
assert result["errors"] == []
assert result["status"] == "completed"
@pytest.mark.parametrize(
"name,data,expected",
[
+7 -1
View File
@@ -23,7 +23,13 @@ import pytest
from gns3server.config import Config
from gns3server.main import parse_arguments as parse
from gns3server.server import Server
from gns3server.server import GRACEFUL_SHUTDOWN_TIMEOUT, Server
def test_uvicorn_config_bounds_graceful_shutdown(config):
uvicorn_config = Server._create_uvicorn_config(None, "127.0.0.1", 3080, False)
assert uvicorn_config.timeout_graceful_shutdown == GRACEFUL_SHUTDOWN_TIMEOUT
def test_locale_check():
+30 -1
View File
@@ -20,7 +20,13 @@ import threading
import pytest
from gns3server.utils import force_unix_path
from gns3server.utils.images import images_directories, list_images, md5sum, remove_checksum
from gns3server.utils.images import (
canonical_docker_image_name,
images_directories,
list_images,
md5sum,
remove_checksum,
)
def test_images_directories(tmpdir, config):
@@ -108,6 +114,29 @@ def test_remove_checksum(tmpdir):
remove_checksum(str(tmpdir / "not_exists"))
@pytest.mark.parametrize(
"reference, expected",
(
("alpine", "library/alpine:latest"),
("alpine:3", "library/alpine:3"),
("library/alpine:3", "library/alpine:3"),
("docker.io/library/alpine:3", "library/alpine:3"),
("index.docker.io/library/alpine:3", "library/alpine:3"),
("localhost/alpine:3", "library/alpine:3"),
("gns3/frr", "gns3/frr:latest"),
("gns3/frr:latest", "gns3/frr:latest"),
("quay.io/some/namespace/image:2.0", "some/namespace/image:2.0"),
("alpine@sha256:" + "a" * 64, "library/alpine@sha256:" + "a" * 64),
("alpine:3@sha256:" + "a" * 64, "library/alpine@sha256:" + "a" * 64),
),
)
def test_canonical_docker_image_name(reference, expected):
assert canonical_docker_image_name(reference) == expected
# the canonical form is stable: canonicalizing twice does not change it
assert canonical_docker_image_name(expected) == expected
@pytest.mark.asyncio
async def test_list_images(tmpdir, config):