mirror of
https://github.com/quay/quay.git
synced 2025-11-17 23:02:34 +03:00
fix(api): implement proper superuser permission model and fix access controls Fixes multiple issues with superuser functionality and implements a comprehensive permission model for FEATURE_SUPERUSERS_FULL_ACCESS: **Permission Model:** - Global Readonly Superusers (auditors): Always have read access to all content, independent of FEATURE_SUPERUSERS_FULL_ACCESS setting - Regular Superusers: Can access /v1/superuser endpoints and their own content. Require FEATURE_SUPERUSERS_FULL_ACCESS=true for cross-namespace read access - Full Access Superusers: Regular superusers with FULL_ACCESS enabled, can perform CRUD on content they don't own - Write operations: Only allowed for full access superusers (global readonly superusers never get write access) **Key Fixes:** 1. Fixed superuser panel endpoints returning 403 when FULL_ACCESS was disabled. Basic panel operations (user list, logs, org list, messages) now work with just FEATURE_SUPER_USERS enabled. 2. Updated decorators to properly differentiate between basic superuser operations and permission bypass operations. 3. Implemented license bypass: Superusers with FULL_ACCESS now bypass license/quota limits when creating or modifying private repositories. 4. Fixed 18 permission checks across 7 files to properly implement cross-namespace access controls for different superuser types. **Changes:** - endpoints/api/__init__.py: Fixed allow_if_superuser(), require_repo_permission, and decorators - endpoints/api/superuser.py: Updated SuperUserAppTokens permission check - endpoints/api/organization.py: Updated 4 GET endpoints to require FULL_ACCESS - endpoints/api/namespacequota.py: Updated 2 GET endpoints to require FULL_ACCESS - endpoints/api/team.py: Updated 2 GET endpoints to require FULL_ACCESS - endpoints/api/prototype.py: Updated 1 GET endpoint to require FULL_ACCESS - endpoints/api/policy.py: Updated auto-prune policy endpoints - endpoints/api/robot.py: Updated robot endpoints - endpoints/api/build.py: Updated repository build logs - endpoints/api/repository.py: Added license bypass for superusers with FULL_ACCESS - endpoints/api/repository_models_pre_oci.py: Updated repository visibility query - endpoints/api/logs.py: Fixed log access to require FULL_ACCESS for permission bypass - endpoints/api/test/test_superuser_full_access.py: Added comprehensive test suite - endpoints/api/test/test_appspecifictoken.py: Updated test mocking and added 403 test - test/test_api_usage.py: Updated test expectations for license bypass behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
414 lines
18 KiB
Python
414 lines
18 KiB
Python
from datetime import datetime, timedelta
|
|
from unittest.mock import patch
|
|
|
|
from data import model
|
|
from endpoints.api.appspecifictokens import AppToken, AppTokens
|
|
from endpoints.api.superuser import SuperUserAppTokens
|
|
from endpoints.api.test.shared import conduct_api_call
|
|
from endpoints.test.shared import client_with_identity
|
|
from test.fixtures import *
|
|
|
|
|
|
def test_app_specific_tokens(app):
|
|
with client_with_identity("devtable", app) as cl:
|
|
# Add an app specific token.
|
|
token_data = {"title": "Testing 123"}
|
|
resp = conduct_api_call(cl, AppTokens, "POST", None, token_data, 200).json
|
|
token_uuid = resp["token"]["uuid"]
|
|
assert "token_code" in resp["token"]
|
|
|
|
# List the tokens and ensure we have the one added.
|
|
resp = conduct_api_call(cl, AppTokens, "GET", None, None, 200).json
|
|
assert len(resp["tokens"])
|
|
assert token_uuid in set([token["uuid"] for token in resp["tokens"]])
|
|
assert not set([token["token_code"] for token in resp["tokens"] if "token_code" in token])
|
|
|
|
# List the tokens expiring soon and ensure the one added is not present.
|
|
resp = conduct_api_call(cl, AppTokens, "GET", {"expiring": True}, None, 200).json
|
|
assert token_uuid not in set([token["uuid"] for token in resp["tokens"]])
|
|
|
|
# Get the token and ensure we have its code (owner can see secret).
|
|
resp = conduct_api_call(cl, AppToken, "GET", {"token_uuid": token_uuid}, None, 200).json
|
|
assert resp["token"]["uuid"] == token_uuid
|
|
assert "token_code" in resp["token"]
|
|
|
|
# Delete the token.
|
|
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": token_uuid}, None, 204)
|
|
|
|
# Ensure the token no longer exists.
|
|
resp = conduct_api_call(cl, AppTokens, "GET", None, None, 200).json
|
|
assert len(resp["tokens"])
|
|
assert token_uuid not in set([token["uuid"] for token in resp["tokens"]])
|
|
|
|
conduct_api_call(cl, AppToken, "GET", {"token_uuid": token_uuid}, None, 404)
|
|
|
|
|
|
def test_delete_expired_app_token(app):
|
|
user = model.user.get_user("devtable")
|
|
expiration = datetime.now() - timedelta(seconds=10)
|
|
token = model.appspecifictoken.create_token(user, "some token", expiration)
|
|
|
|
with client_with_identity("devtable", app) as cl:
|
|
# Delete the token.
|
|
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": token.uuid}, None, 204)
|
|
|
|
|
|
def test_list_tokens_user_scoped(app):
|
|
"""Test that all user types only see their own tokens on /v1/user/apptoken
|
|
|
|
Tests regular users, superusers, and global readonly superusers to ensure
|
|
proper token scoping - users should only see their own tokens on this endpoint.
|
|
"""
|
|
# Test freshuser (regular user)
|
|
freshuser_token_data = {"title": "Freshuser Test Token"}
|
|
with client_with_identity("freshuser", app) as cl:
|
|
# Create a token for this user
|
|
resp = conduct_api_call(cl, AppTokens, "POST", None, freshuser_token_data, 200).json
|
|
freshuser_token_uuid = resp["token"]["uuid"]
|
|
|
|
# List tokens - freshuser should see their own token
|
|
resp = conduct_api_call(cl, AppTokens, "GET", None, None, 200).json
|
|
token_uuids = set([token["uuid"] for token in resp["tokens"]])
|
|
assert freshuser_token_uuid in token_uuids
|
|
|
|
# Clean up
|
|
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": freshuser_token_uuid}, None, 204)
|
|
|
|
# Test devtable (superuser)
|
|
devtable_token_data = {"title": "Devtable Test Token"}
|
|
with client_with_identity("devtable", app) as cl:
|
|
# Create a token for this user
|
|
resp = conduct_api_call(cl, AppTokens, "POST", None, devtable_token_data, 200).json
|
|
devtable_token_uuid = resp["token"]["uuid"]
|
|
|
|
# List tokens - superuser should see their own token but not freshuser's on /v1/user/apptoken
|
|
resp = conduct_api_call(cl, AppTokens, "GET", None, None, 200).json
|
|
token_uuids = set([token["uuid"] for token in resp["tokens"]])
|
|
assert devtable_token_uuid in token_uuids
|
|
assert freshuser_token_uuid not in token_uuids
|
|
|
|
# Clean up
|
|
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": devtable_token_uuid}, None, 204)
|
|
|
|
# Test globalreadonlysuperuser (global readonly superuser)
|
|
global_ro_token_data = {"title": "Global RO Test Token"}
|
|
with client_with_identity("globalreadonlysuperuser", app) as cl:
|
|
# Create a token for this user
|
|
resp = conduct_api_call(cl, AppTokens, "POST", None, global_ro_token_data, 200).json
|
|
global_ro_token_uuid = resp["token"]["uuid"]
|
|
|
|
# List tokens - global readonly superuser should see their own token but not others'
|
|
resp = conduct_api_call(cl, AppTokens, "GET", None, None, 200).json
|
|
token_uuids = set([token["uuid"] for token in resp["tokens"]])
|
|
assert global_ro_token_uuid in token_uuids
|
|
assert freshuser_token_uuid not in token_uuids
|
|
assert devtable_token_uuid not in token_uuids
|
|
|
|
# Clean up
|
|
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": global_ro_token_uuid}, None, 204)
|
|
|
|
|
|
def test_list_expiring_tokens_user_scoped(app):
|
|
"""Test that expiring token filtering is properly scoped for all user types
|
|
|
|
Tests that the expiring=True parameter only returns expiring tokens belonging
|
|
to the authenticated user, regardless of user type (regular, superuser, global readonly).
|
|
"""
|
|
soon_expiration = datetime.now() + timedelta(minutes=1)
|
|
far_expiration = datetime.now() + timedelta(days=30)
|
|
|
|
# Test freshuser (regular user)
|
|
with client_with_identity("freshuser", app) as cl:
|
|
# Create expiring and non-expiring tokens via API
|
|
expiring_resp = conduct_api_call(
|
|
cl, AppTokens, "POST", None, {"title": "Freshuser Expiring"}, 200
|
|
).json
|
|
expiring_uuid = expiring_resp["token"]["uuid"]
|
|
|
|
normal_resp = conduct_api_call(
|
|
cl, AppTokens, "POST", None, {"title": "Freshuser Normal"}, 200
|
|
).json
|
|
normal_uuid = normal_resp["token"]["uuid"]
|
|
|
|
# Update expiration times directly on the tokens
|
|
expiring_token = model.appspecifictoken.AppSpecificAuthToken.get(
|
|
model.appspecifictoken.AppSpecificAuthToken.uuid == expiring_uuid
|
|
)
|
|
expiring_token.expiration = soon_expiration
|
|
expiring_token.save()
|
|
|
|
normal_token = model.appspecifictoken.AppSpecificAuthToken.get(
|
|
model.appspecifictoken.AppSpecificAuthToken.uuid == normal_uuid
|
|
)
|
|
normal_token.expiration = far_expiration
|
|
normal_token.save()
|
|
|
|
# Query with expiring=True - should only see expiring token
|
|
resp = conduct_api_call(cl, AppTokens, "GET", {"expiring": True}, None, 200).json
|
|
token_uuids = set([token["uuid"] for token in resp["tokens"]])
|
|
assert expiring_uuid in token_uuids
|
|
assert normal_uuid not in token_uuids
|
|
|
|
# Clean up
|
|
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": expiring_uuid}, None, 204)
|
|
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": normal_uuid}, None, 204)
|
|
|
|
# Test devtable (superuser)
|
|
with client_with_identity("devtable", app) as cl:
|
|
# Create expiring and non-expiring tokens via API
|
|
expiring_resp = conduct_api_call(
|
|
cl, AppTokens, "POST", None, {"title": "Devtable Expiring"}, 200
|
|
).json
|
|
expiring_uuid = expiring_resp["token"]["uuid"]
|
|
|
|
normal_resp = conduct_api_call(
|
|
cl, AppTokens, "POST", None, {"title": "Devtable Normal"}, 200
|
|
).json
|
|
normal_uuid = normal_resp["token"]["uuid"]
|
|
|
|
# Update expiration times
|
|
expiring_token = model.appspecifictoken.AppSpecificAuthToken.get(
|
|
model.appspecifictoken.AppSpecificAuthToken.uuid == expiring_uuid
|
|
)
|
|
expiring_token.expiration = soon_expiration
|
|
expiring_token.save()
|
|
|
|
normal_token = model.appspecifictoken.AppSpecificAuthToken.get(
|
|
model.appspecifictoken.AppSpecificAuthToken.uuid == normal_uuid
|
|
)
|
|
normal_token.expiration = far_expiration
|
|
normal_token.save()
|
|
|
|
# Query with expiring=True - superuser should only see their own expiring token
|
|
resp = conduct_api_call(cl, AppTokens, "GET", {"expiring": True}, None, 200).json
|
|
token_uuids = set([token["uuid"] for token in resp["tokens"]])
|
|
assert expiring_uuid in token_uuids
|
|
assert normal_uuid not in token_uuids
|
|
|
|
# Clean up
|
|
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": expiring_uuid}, None, 204)
|
|
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": normal_uuid}, None, 204)
|
|
|
|
# Test globalreadonlysuperuser (global readonly superuser)
|
|
with client_with_identity("globalreadonlysuperuser", app) as cl:
|
|
# Create expiring and non-expiring tokens via API
|
|
expiring_resp = conduct_api_call(
|
|
cl, AppTokens, "POST", None, {"title": "Global RO Expiring"}, 200
|
|
).json
|
|
expiring_uuid = expiring_resp["token"]["uuid"]
|
|
|
|
normal_resp = conduct_api_call(
|
|
cl, AppTokens, "POST", None, {"title": "Global RO Normal"}, 200
|
|
).json
|
|
normal_uuid = normal_resp["token"]["uuid"]
|
|
|
|
# Update expiration times
|
|
expiring_token = model.appspecifictoken.AppSpecificAuthToken.get(
|
|
model.appspecifictoken.AppSpecificAuthToken.uuid == expiring_uuid
|
|
)
|
|
expiring_token.expiration = soon_expiration
|
|
expiring_token.save()
|
|
|
|
normal_token = model.appspecifictoken.AppSpecificAuthToken.get(
|
|
model.appspecifictoken.AppSpecificAuthToken.uuid == normal_uuid
|
|
)
|
|
normal_token.expiration = far_expiration
|
|
normal_token.save()
|
|
|
|
# Query with expiring=True - global RO superuser should only see their own expiring token
|
|
resp = conduct_api_call(cl, AppTokens, "GET", {"expiring": True}, None, 200).json
|
|
token_uuids = set([token["uuid"] for token in resp["tokens"]])
|
|
assert expiring_uuid in token_uuids
|
|
assert normal_uuid not in token_uuids
|
|
|
|
# Clean up
|
|
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": expiring_uuid}, None, 204)
|
|
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": normal_uuid}, None, 204)
|
|
|
|
|
|
def test_list_tokens_no_token_codes(app):
|
|
"""Test that token codes are never included in list responses"""
|
|
devtable_user = model.user.get_user("devtable")
|
|
token = model.appspecifictoken.create_token(devtable_user, "Test Token")
|
|
|
|
try:
|
|
with client_with_identity("devtable", app) as cl:
|
|
# List tokens should never include token codes
|
|
resp = conduct_api_call(cl, AppTokens, "GET", None, None, 200).json
|
|
for token_data in resp["tokens"]:
|
|
assert "token_code" not in token_data
|
|
assert "uuid" in token_data
|
|
assert "title" in token_data
|
|
assert "last_accessed" in token_data
|
|
assert "created" in token_data
|
|
assert "expiration" in token_data
|
|
finally:
|
|
# Clean up
|
|
token.delete_instance()
|
|
|
|
|
|
def test_individual_token_access_regular_user(app):
|
|
"""Test that regular users can only access their own tokens on /v1/user/apptoken"""
|
|
freshuser = model.user.get_user("freshuser")
|
|
reader_user = model.user.get_user("reader")
|
|
freshuser_token = model.appspecifictoken.create_token(freshuser, "Freshuser Token")
|
|
reader_token = model.appspecifictoken.create_token(reader_user, "Reader Token")
|
|
|
|
try:
|
|
with client_with_identity("freshuser", app) as cl:
|
|
# Should be able to access own token with secret
|
|
resp = conduct_api_call(
|
|
cl, AppToken, "GET", {"token_uuid": freshuser_token.uuid}, None, 200
|
|
).json
|
|
assert resp["token"]["uuid"] == freshuser_token.uuid
|
|
assert "token_code" in resp["token"]
|
|
|
|
# Should NOT be able to access other user's token
|
|
conduct_api_call(cl, AppToken, "GET", {"token_uuid": reader_token.uuid}, None, 404)
|
|
finally:
|
|
freshuser_token.delete_instance()
|
|
reader_token.delete_instance()
|
|
|
|
|
|
def test_individual_token_access_superuser(app):
|
|
"""Test that superusers can only access their own tokens on /v1/user/apptoken"""
|
|
devtable_user = model.user.get_user("devtable")
|
|
freshuser = model.user.get_user("freshuser")
|
|
devtable_token = model.appspecifictoken.create_token(devtable_user, "DevTable Token")
|
|
freshuser_token = model.appspecifictoken.create_token(freshuser, "Freshuser Token")
|
|
|
|
try:
|
|
with client_with_identity("devtable", app) as cl:
|
|
# Superuser should be able to access own token with secret on /v1/user/apptoken
|
|
resp = conduct_api_call(
|
|
cl, AppToken, "GET", {"token_uuid": devtable_token.uuid}, None, 200
|
|
).json
|
|
assert resp["token"]["uuid"] == devtable_token.uuid
|
|
assert "token_code" in resp["token"]
|
|
|
|
# Superuser should NOT be able to access other user's token on /v1/user/apptoken
|
|
conduct_api_call(cl, AppToken, "GET", {"token_uuid": freshuser_token.uuid}, None, 404)
|
|
finally:
|
|
devtable_token.delete_instance()
|
|
freshuser_token.delete_instance()
|
|
|
|
|
|
def test_individual_token_access_global_readonly_superuser(app):
|
|
"""Test that global readonly superusers can only access their own tokens on /v1/user/apptoken"""
|
|
devtable_user = model.user.get_user("devtable")
|
|
global_ro_user = model.user.get_user("globalreadonlysuperuser")
|
|
devtable_token = model.appspecifictoken.create_token(devtable_user, "DevTable Token")
|
|
global_ro_token = model.appspecifictoken.create_token(global_ro_user, "Global RO Token")
|
|
|
|
try:
|
|
with client_with_identity("globalreadonlysuperuser", app) as cl:
|
|
# Global RO superuser should be able to access own token with secret
|
|
resp = conduct_api_call(
|
|
cl, AppToken, "GET", {"token_uuid": global_ro_token.uuid}, None, 200
|
|
).json
|
|
assert resp["token"]["uuid"] == global_ro_token.uuid
|
|
assert "token_code" in resp["token"]
|
|
|
|
# Global RO superuser should NOT be able to access other user's token on /v1/user/apptoken
|
|
conduct_api_call(cl, AppToken, "GET", {"token_uuid": devtable_token.uuid}, None, 404)
|
|
finally:
|
|
devtable_token.delete_instance()
|
|
global_ro_token.delete_instance()
|
|
|
|
|
|
def test_superuser_endpoint_sees_all_tokens(app):
|
|
"""Test that superusers and global readonly superusers can see all tokens on /v1/superuser/apptokens"""
|
|
devtable_user = model.user.get_user("devtable")
|
|
reader_user = model.user.get_user("reader")
|
|
|
|
devtable_token = model.appspecifictoken.create_token(devtable_user, "DevTable Token")
|
|
reader_token = model.appspecifictoken.create_token(reader_user, "Reader Token")
|
|
|
|
try:
|
|
# Test superuser
|
|
with client_with_identity("devtable", app) as cl:
|
|
# On /v1/superuser/apptokens, superuser should see all tokens
|
|
resp = conduct_api_call(cl, SuperUserAppTokens, "GET", None, None, 200).json
|
|
token_uuids = set([token["uuid"] for token in resp["tokens"]])
|
|
|
|
assert devtable_token.uuid in token_uuids
|
|
assert reader_token.uuid in token_uuids
|
|
for token in resp["tokens"]:
|
|
assert "token_code" not in token
|
|
|
|
# Test global readonly superuser
|
|
# Mock global readonly superuser by mocking the permission functions
|
|
with patch(
|
|
"endpoints.api.superuser.allow_if_global_readonly_superuser", return_value=True
|
|
), patch("endpoints.api.superuser.allow_if_superuser_with_full_access", return_value=False):
|
|
with client_with_identity("reader", app) as cl:
|
|
# On /v1/superuser/apptokens, global readonly superuser should see all tokens
|
|
resp = conduct_api_call(cl, SuperUserAppTokens, "GET", None, None, 200).json
|
|
token_uuids = set([token["uuid"] for token in resp["tokens"]])
|
|
|
|
assert devtable_token.uuid in token_uuids
|
|
assert reader_token.uuid in token_uuids
|
|
for token in resp["tokens"]:
|
|
assert "token_code" not in token
|
|
|
|
finally:
|
|
# Clean up
|
|
devtable_token.delete_instance()
|
|
reader_token.delete_instance()
|
|
|
|
|
|
def test_superuser_endpoint_requires_full_access(app):
|
|
"""Test that regular superusers without FULL_ACCESS get 403 on /v1/superuser/apptokens"""
|
|
# Mock a regular superuser (not global readonly) without FULL_ACCESS
|
|
with patch(
|
|
"endpoints.api.superuser.allow_if_global_readonly_superuser", return_value=False
|
|
), patch("endpoints.api.superuser.allow_if_superuser_with_full_access", return_value=False):
|
|
with client_with_identity("devtable", app) as cl:
|
|
# Regular superuser without FULL_ACCESS should get 403
|
|
conduct_api_call(cl, SuperUserAppTokens, "GET", None, None, 403)
|
|
|
|
|
|
def test_superuser_endpoint_expiring_tokens(app):
|
|
"""Test expiring token filtering on /v1/superuser/apptokens"""
|
|
devtable_user = model.user.get_user("devtable")
|
|
reader_user = model.user.get_user("reader")
|
|
|
|
# Create expiring and non-expiring tokens for both users
|
|
soon_expiration = datetime.now() + timedelta(minutes=1)
|
|
far_expiration = datetime.now() + timedelta(days=30)
|
|
|
|
devtable_expiring = model.appspecifictoken.create_token(
|
|
devtable_user, "DevTable Expiring", soon_expiration
|
|
)
|
|
devtable_normal = model.appspecifictoken.create_token(
|
|
devtable_user, "DevTable Normal", far_expiration
|
|
)
|
|
reader_expiring = model.appspecifictoken.create_token(
|
|
reader_user, "Reader Expiring", soon_expiration
|
|
)
|
|
reader_normal = model.appspecifictoken.create_token(
|
|
reader_user, "Reader Normal", far_expiration
|
|
)
|
|
|
|
try:
|
|
with client_with_identity("devtable", app) as cl:
|
|
# On /v1/superuser/apptokens with expiring=True, should see all expiring tokens
|
|
resp = conduct_api_call(
|
|
cl, SuperUserAppTokens, "GET", {"expiring": True}, None, 200
|
|
).json
|
|
token_uuids = set([token["uuid"] for token in resp["tokens"]])
|
|
|
|
# Should see expiring tokens from both users
|
|
assert devtable_expiring.uuid in token_uuids
|
|
assert reader_expiring.uuid in token_uuids
|
|
# Should not see non-expiring tokens
|
|
assert devtable_normal.uuid not in token_uuids
|
|
assert reader_normal.uuid not in token_uuids
|
|
|
|
finally:
|
|
# Clean up
|
|
devtable_expiring.delete_instance()
|
|
devtable_normal.delete_instance()
|
|
reader_expiring.delete_instance()
|
|
reader_normal.delete_instance()
|