mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-03 10:36:45 +09:00
10 extension-owned suites (surface, extraction, import-zip, php errors/ limits, pipelines alias, plugin library, promote, reports, ssh import) move to the extension repo via the SERVERKIT_WORDPRESS_DIR loader. Panel-core suites (bridge, hooks-absent, seeding, authz sweeps) updated for WP-absent behavior; conftest seeds the manifest onto test plugin rows.
233 lines
9.8 KiB
Python
233 lines
9.8 KiB
Python
"""Tests for per-resource access grants (#33 — per-site ACL)."""
|
|
|
|
|
|
def _mk_user(db, username, role='developer'):
|
|
from app.models import User
|
|
from werkzeug.security import generate_password_hash
|
|
u = User(email=f'{username}@t.local', username=username,
|
|
password_hash=generate_password_hash('x'), role=role, is_active=True)
|
|
db.session.add(u)
|
|
db.session.commit()
|
|
return u
|
|
|
|
|
|
def _token(user_id):
|
|
from flask_jwt_extended import create_access_token
|
|
return {'Authorization': f'Bearer {create_access_token(identity=user_id)}'}
|
|
|
|
|
|
def test_grant_makes_app_visible_and_accessible(app, client):
|
|
from app import db
|
|
from app.models import Application
|
|
|
|
owner = _mk_user(db, 'g_owner')
|
|
grantee = _mk_user(db, 'g_grantee')
|
|
a = Application(name='shared-app', app_type='php', user_id=owner.id)
|
|
db.session.add(a)
|
|
db.session.commit()
|
|
app_id = a.id
|
|
|
|
# Before the grant: the grantee neither sees nor can open the app.
|
|
r = client.get('/api/v1/apps', headers=_token(grantee.id))
|
|
assert 'shared-app' not in {x['name'] for x in r.get_json()['apps']}
|
|
r = client.get(f'/api/v1/apps/{app_id}', headers=_token(grantee.id))
|
|
assert r.status_code == 403
|
|
|
|
# Owner shares it.
|
|
r = client.post(f'/api/v1/apps/{app_id}/grants', json={'user_id': grantee.id}, headers=_token(owner.id))
|
|
assert r.status_code == 201
|
|
|
|
# Now the grantee sees it in their list and can open it.
|
|
r = client.get('/api/v1/apps', headers=_token(grantee.id))
|
|
assert 'shared-app' in {x['name'] for x in r.get_json()['apps']}
|
|
r = client.get(f'/api/v1/apps/{app_id}', headers=_token(grantee.id))
|
|
assert r.status_code == 200
|
|
|
|
# An unrelated user still can't.
|
|
other = _mk_user(db, 'g_other')
|
|
r = client.get(f'/api/v1/apps/{app_id}', headers=_token(other.id))
|
|
assert r.status_code == 403
|
|
|
|
|
|
def test_grant_management_permissions(app, client):
|
|
from app import db
|
|
from app.models import Application
|
|
|
|
owner = _mk_user(db, 'm_owner')
|
|
grantee = _mk_user(db, 'm_grantee')
|
|
stranger = _mk_user(db, 'm_stranger')
|
|
a = Application(name='m-app', app_type='php', user_id=owner.id)
|
|
db.session.add(a)
|
|
db.session.commit()
|
|
app_id = a.id
|
|
|
|
# A non-owner non-admin can't manage sharing.
|
|
r = client.post(f'/api/v1/apps/{app_id}/grants', json={'user_id': grantee.id}, headers=_token(stranger.id))
|
|
assert r.status_code == 403
|
|
|
|
# Owner grants, lists, and the owner can't be granted to themselves.
|
|
r = client.post(f'/api/v1/apps/{app_id}/grants', json={'user_id': grantee.id}, headers=_token(owner.id))
|
|
assert r.status_code == 201
|
|
grant_id = r.get_json()['grant']['id']
|
|
|
|
r = client.get(f'/api/v1/apps/{app_id}/grants', headers=_token(owner.id))
|
|
assert r.status_code == 200 and len(r.get_json()['grants']) == 1
|
|
|
|
r = client.post(f'/api/v1/apps/{app_id}/grants', json={'user_id': owner.id}, headers=_token(owner.id))
|
|
assert r.status_code == 400
|
|
|
|
# Revoke -> the grantee loses access.
|
|
r = client.delete(f'/api/v1/apps/{app_id}/grants/{grant_id}', headers=_token(owner.id))
|
|
assert r.status_code == 200
|
|
r = client.get(f'/api/v1/apps/{app_id}', headers=_token(grantee.id))
|
|
assert r.status_code == 403
|
|
|
|
|
|
def test_grant_is_idempotent(app, client):
|
|
from app import db
|
|
from app.models import Application
|
|
from app.models.workspace import ResourceGrant
|
|
|
|
owner = _mk_user(db, 'i_owner')
|
|
grantee = _mk_user(db, 'i_grantee')
|
|
a = Application(name='i-app', app_type='php', user_id=owner.id)
|
|
db.session.add(a)
|
|
db.session.commit()
|
|
|
|
client.post(f'/api/v1/apps/{a.id}/grants', json={'user_id': grantee.id}, headers=_token(owner.id))
|
|
client.post(f'/api/v1/apps/{a.id}/grants', json={'user_id': grantee.id}, headers=_token(owner.id))
|
|
assert ResourceGrant.query.filter_by(resource_type='application', resource_id=a.id, user_id=grantee.id).count() == 1
|
|
|
|
|
|
def test_viewer_grant_is_read_only(app, client):
|
|
from app import db
|
|
from app.models import Application
|
|
from app.services.resource_grant_service import ResourceGrantService
|
|
|
|
owner = _mk_user(db, 'v_owner')
|
|
viewer = _mk_user(db, 'v_viewer')
|
|
a = Application(name='v-app', app_type='php', user_id=owner.id, root_path='/srv/v')
|
|
db.session.add(a)
|
|
db.session.commit()
|
|
ResourceGrantService.grant(user_id=viewer.id, resource_type='application',
|
|
resource_id=a.id, granted_by=owner.id, role='viewer')
|
|
|
|
# A viewer can read the app and its linked list...
|
|
assert client.get(f'/api/v1/apps/{a.id}', headers=_token(viewer.id)).status_code == 200
|
|
assert client.get(f'/api/v1/apps/{a.id}/linked', headers=_token(viewer.id)).status_code == 200
|
|
# ...but operating it (editor-only) is denied.
|
|
assert client.post(f'/api/v1/apps/{a.id}/start', headers=_token(viewer.id)).status_code == 403
|
|
|
|
|
|
def test_editor_grant_can_operate(app, client):
|
|
from app import db
|
|
from app.models import Application
|
|
from app.services.resource_grant_service import ResourceGrantService
|
|
|
|
owner = _mk_user(db, 'e_owner')
|
|
editor = _mk_user(db, 'e_editor')
|
|
a = Application(name='e-app', app_type='php', user_id=owner.id, root_path='/srv/e')
|
|
db.session.add(a)
|
|
db.session.commit()
|
|
ResourceGrantService.grant(user_id=editor.id, resource_type='application',
|
|
resource_id=a.id, granted_by=owner.id, role='editor')
|
|
|
|
# An editor passes the operate access gate (the start action itself may fail
|
|
# without Docker, but it must NOT be a 403 access denial).
|
|
assert client.post(f'/api/v1/apps/{a.id}/start', headers=_token(editor.id)).status_code != 403
|
|
# ...but delete stays owner-only — even an editor cannot delete the app.
|
|
assert client.delete(f'/api/v1/apps/{a.id}', headers=_token(editor.id)).status_code == 403
|
|
|
|
|
|
def test_grant_role_validation_and_default(app, client):
|
|
from app import db
|
|
from app.models import Application
|
|
|
|
owner = _mk_user(db, 'rv_owner')
|
|
g1 = _mk_user(db, 'rv_g1')
|
|
g2 = _mk_user(db, 'rv_g2')
|
|
a = Application(name='rv-app', app_type='php', user_id=owner.id)
|
|
db.session.add(a)
|
|
db.session.commit()
|
|
|
|
# Default role is editor.
|
|
r = client.post(f'/api/v1/apps/{a.id}/grants', json={'user_id': g1.id}, headers=_token(owner.id))
|
|
assert r.status_code == 201 and r.get_json()['grant']['role'] == 'editor'
|
|
|
|
# Explicit viewer is honored.
|
|
r = client.post(f'/api/v1/apps/{a.id}/grants', json={'user_id': g2.id, 'role': 'viewer'}, headers=_token(owner.id))
|
|
assert r.status_code == 201 and r.get_json()['grant']['role'] == 'viewer'
|
|
|
|
# An unknown role is rejected.
|
|
r = client.post(f'/api/v1/apps/{a.id}/grants', json={'user_id': g2.id, 'role': 'superuser'}, headers=_token(owner.id))
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_grant_extends_to_other_app_blueprints(app, client):
|
|
"""The grant-aware helper now gates the other app blueprints too — verify the
|
|
sweep wired it into builds.py (a separate blueprint from apps.py)."""
|
|
from app import db
|
|
from app.models import Application
|
|
from app.services.resource_grant_service import ResourceGrantService
|
|
|
|
owner = _mk_user(db, 'bp_owner')
|
|
grantee = _mk_user(db, 'bp_grantee')
|
|
stranger = _mk_user(db, 'bp_stranger')
|
|
a = Application(name='bp-app', app_type='php', user_id=owner.id)
|
|
db.session.add(a)
|
|
db.session.commit()
|
|
|
|
url = f'/api/v1/builds/apps/{a.id}/deployments' # GET, read
|
|
assert client.get(url, headers=_token(stranger.id)).status_code == 403
|
|
assert client.get(url, headers=_token(owner.id)).status_code == 200
|
|
|
|
ResourceGrantService.grant(user_id=grantee.id, resource_type='application',
|
|
resource_id=a.id, granted_by=owner.id, role='viewer')
|
|
assert client.get(url, headers=_token(grantee.id)).status_code == 200
|
|
|
|
|
|
def test_grant_opens_python_read_gate(app, client):
|
|
"""python.py's get_app_or_404 (the shared read gate) honors grants; its mutating
|
|
endpoints are independently @admin_required, so a grant only opens the reads."""
|
|
from app import db
|
|
from app.models import Application
|
|
from app.services.resource_grant_service import ResourceGrantService
|
|
|
|
owner = _mk_user(db, 'py_owner')
|
|
grantee = _mk_user(db, 'py_grantee')
|
|
stranger = _mk_user(db, 'py_stranger')
|
|
a = Application(name='py-app', app_type='flask', user_id=owner.id, root_path='/srv/py')
|
|
db.session.add(a)
|
|
db.session.commit()
|
|
|
|
url = f'/api/v1/python/apps/{a.id}/packages' # GET, read (no @admin_required)
|
|
assert client.get(url, headers=_token(stranger.id)).status_code == 403 # gate denies
|
|
ResourceGrantService.grant(user_id=grantee.id, resource_type='application',
|
|
resource_id=a.id, granted_by=owner.id, role='viewer')
|
|
# The grantee passes the read gate (the read itself may be empty, but not a 403).
|
|
assert client.get(url, headers=_token(grantee.id)).status_code != 403
|
|
|
|
|
|
def test_grant_enables_wordpress_per_site_routes(app, client, wp_extension):
|
|
from app import db
|
|
from app.models import Application, WordPressSite
|
|
from app.services.resource_grant_service import ResourceGrantService
|
|
|
|
owner = _mk_user(db, 'wpg_owner')
|
|
grantee = _mk_user(db, 'wpg_grantee')
|
|
a = Application(name='wpg-app', app_type='wordpress', user_id=owner.id)
|
|
db.session.add(a)
|
|
db.session.commit()
|
|
db.session.add(WordPressSite(application_id=a.id, is_production=True))
|
|
db.session.commit()
|
|
|
|
# A WP per-site route (guarded by _owner_or_admin_app) is denied before the grant.
|
|
r = client.get(f'/api/v1/wordpress/sites/{a.id}/updates', headers=_token(grantee.id))
|
|
assert r.status_code == 403
|
|
|
|
# After a grant, the same route is reachable — one helper covers every WP route.
|
|
ResourceGrantService.grant(user_id=grantee.id, resource_type='application',
|
|
resource_id=a.id, granted_by=owner.id)
|
|
r = client.get(f'/api/v1/wordpress/sites/{a.id}/updates', headers=_token(grantee.id))
|
|
assert r.status_code == 200
|