mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-24 12:07:58 +09:00
POST /api/v1/workspaces/ was gated only by @jwt_required(), so any authenticated user - including the read-only viewer role - could create a workspace, automatically become its owner, then add arbitrary members and mint workspace-scoped API keys (GHSA-r4q7-x795-vr4j, CWE-862). Add @admin_required, matching the owner/admin checks every other mutating workspace route already enforces, plus a regression test asserting viewer and developer JWTs get 403 while an admin still creates. Co-authored-by: Alex <134992700+campguyuz@users.noreply.github.com>
531 lines
23 KiB
Python
531 lines
23 KiB
Python
"""Tests for the workspace scoping foundation (#33 core)."""
|
|
import pytest
|
|
|
|
|
|
def _mk_user(db, username, role='admin'):
|
|
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)}'}
|
|
|
|
|
|
# ---- ensure_default_workspace --------------------------------------------
|
|
|
|
def test_ensure_default_workspace_idempotent(app):
|
|
from app.services.workspace_service import WorkspaceService
|
|
from app.models.workspace import Workspace
|
|
ws1 = WorkspaceService.ensure_default_workspace()
|
|
ws2 = WorkspaceService.ensure_default_workspace()
|
|
assert ws1.id == ws2.id
|
|
assert Workspace.query.filter_by(slug='default').count() == 1
|
|
# Quota columns must be concrete ints (0), never NULL — the model does int math on them.
|
|
assert ws1.max_users == 0
|
|
|
|
|
|
# ---- resolve_workspace_id -------------------------------------------------
|
|
|
|
def test_resolve_workspace_id_branches(app):
|
|
from app import db
|
|
from app.services.workspace_service import WorkspaceService
|
|
|
|
admin = _mk_user(db, 'r_admin', 'admin')
|
|
owner = _mk_user(db, 'r_owner', 'developer')
|
|
outsider = _mk_user(db, 'r_outsider', 'developer')
|
|
|
|
# No active context.
|
|
assert WorkspaceService.resolve_workspace_id(outsider, None) is None
|
|
assert WorkspaceService.resolve_workspace_id(outsider, '') is None
|
|
assert WorkspaceService.resolve_workspace_id(outsider, 'all') is None
|
|
|
|
# Malformed / unknown -> lenient fall back to no scope (never raises).
|
|
assert WorkspaceService.resolve_workspace_id(outsider, 'abc') is None
|
|
assert WorkspaceService.resolve_workspace_id(outsider, 99999) is None
|
|
|
|
# A workspace owned by `owner` (creator becomes owner-member).
|
|
ws = WorkspaceService.create_workspace({'name': 'Acme'}, owner.id)
|
|
|
|
# A non-member non-admin falls back to no scope (no error, but no access either).
|
|
assert WorkspaceService.resolve_workspace_id(outsider, ws.id) is None
|
|
|
|
# An admin who is NOT a member resolves it (admin bypass).
|
|
assert WorkspaceService.resolve_workspace_id(admin, ws.id) == ws.id
|
|
|
|
# A member resolves it.
|
|
WorkspaceService.add_member(ws.id, outsider.id, 'member')
|
|
assert WorkspaceService.resolve_workspace_id(outsider, ws.id) == ws.id
|
|
|
|
|
|
def test_resolve_deactivated_user_falls_back(app):
|
|
from app import db
|
|
from app.services.workspace_service import WorkspaceService
|
|
|
|
admin = _mk_user(db, 'd_admin', 'admin')
|
|
ws = WorkspaceService.create_workspace({'name': 'DW'}, admin.id)
|
|
member = _mk_user(db, 'd_member', 'developer')
|
|
WorkspaceService.add_member(ws.id, member.id, 'member')
|
|
member.is_active = False
|
|
db.session.commit()
|
|
|
|
# A deactivated account doesn't drive workspace scoping — it falls back to no scope.
|
|
assert WorkspaceService.resolve_workspace_id(member, ws.id) is None
|
|
assert WorkspaceService.resolve_workspace_id(member, None) is None
|
|
|
|
|
|
# ---- scope_query ----------------------------------------------------------
|
|
|
|
def test_scope_query_branches(app):
|
|
from app import db
|
|
from app.services.workspace_service import WorkspaceService
|
|
from app.models import Application
|
|
|
|
admin = _mk_user(db, 'q_admin', 'admin')
|
|
dev = _mk_user(db, 'q_dev', 'developer')
|
|
a1 = Application(name='a1', app_type='php', user_id=dev.id, workspace_id=1)
|
|
a2 = Application(name='a2', app_type='php', user_id=admin.id, workspace_id=2)
|
|
db.session.add_all([a1, a2])
|
|
db.session.commit()
|
|
|
|
def names(q):
|
|
return {a.name for a in q.all()}
|
|
|
|
# Active workspace context -> filter by workspace_id.
|
|
q = WorkspaceService.scope_query(Application.query, Application, dev, workspace_id=1, owner_attr='user_id')
|
|
assert names(q) == {'a1'}
|
|
|
|
# No context, non-admin, owner_attr -> own rows only (prior behavior).
|
|
q = WorkspaceService.scope_query(Application.query, Application, dev, workspace_id=None, owner_attr='user_id')
|
|
assert names(q) == {'a1'}
|
|
|
|
# No context, admin -> everything (prior behavior).
|
|
q = WorkspaceService.scope_query(Application.query, Application, admin, workspace_id=None, owner_attr='user_id')
|
|
assert names(q) == {'a1', 'a2'}
|
|
|
|
# No context, owner_attr=None (a global resource like servers) -> everything, even for a non-admin.
|
|
q = WorkspaceService.scope_query(Application.query, Application, dev, workspace_id=None, owner_attr=None)
|
|
assert names(q) == {'a1', 'a2'}
|
|
|
|
|
|
# ---- API: applications ----------------------------------------------------
|
|
|
|
def test_get_apps_scoping_api(app, client):
|
|
from app import db
|
|
from app.services.workspace_service import WorkspaceService
|
|
from app.models import Application
|
|
|
|
admin = _mk_user(db, 'api_admin', 'admin')
|
|
dev = _mk_user(db, 'api_dev', 'developer')
|
|
ws = WorkspaceService.create_workspace({'name': 'WS1'}, admin.id)
|
|
other = WorkspaceService.create_workspace({'name': 'WS2'}, admin.id)
|
|
db.session.add_all([
|
|
Application(name='in-ws', app_type='php', user_id=dev.id, workspace_id=ws.id),
|
|
Application(name='other-ws', app_type='php', user_id=dev.id, workspace_id=other.id),
|
|
])
|
|
db.session.commit()
|
|
|
|
# No context: dev sees its own apps regardless of workspace (prior behavior preserved).
|
|
r = client.get('/api/v1/apps', headers=_token(dev.id))
|
|
assert r.status_code == 200
|
|
assert {a['name'] for a in r.get_json()['apps']} == {'in-ws', 'other-ws'}
|
|
|
|
# Requesting a workspace the dev isn't a member of -> lenient fall back to no
|
|
# scope (stale/forbidden context never breaks the page; shows own apps).
|
|
r = client.get('/api/v1/apps', headers={**_token(dev.id), 'X-Workspace-Id': str(ws.id)})
|
|
assert r.status_code == 200
|
|
assert {a['name'] for a in r.get_json()['apps']} == {'in-ws', 'other-ws'}
|
|
|
|
# Once a member, the list is filtered to that workspace.
|
|
WorkspaceService.add_member(ws.id, dev.id, 'member')
|
|
r = client.get('/api/v1/apps', headers={**_token(dev.id), 'X-Workspace-Id': str(ws.id)})
|
|
assert r.status_code == 200
|
|
assert {a['name'] for a in r.get_json()['apps']} == {'in-ws'}
|
|
|
|
|
|
def test_workspace_context_never_broadens_access(app, client):
|
|
"""A member activating a workspace must NOT see another user's app that lives
|
|
in the same workspace — scoping only narrows within the user's own rows."""
|
|
from app import db
|
|
from app.services.workspace_service import WorkspaceService
|
|
from app.models import Application
|
|
|
|
admin = _mk_user(db, 'esc_admin', 'admin')
|
|
me = _mk_user(db, 'esc_me', 'developer')
|
|
other = _mk_user(db, 'esc_other', 'developer')
|
|
ws = WorkspaceService.create_workspace({'name': 'Shared'}, admin.id)
|
|
WorkspaceService.add_member(ws.id, me.id, 'member')
|
|
db.session.add_all([
|
|
Application(name='mine', app_type='php', user_id=me.id, workspace_id=ws.id),
|
|
Application(name='theirs', app_type='php', user_id=other.id, workspace_id=ws.id),
|
|
])
|
|
db.session.commit()
|
|
|
|
# `me` is a member of the shared workspace but must still only see its own app.
|
|
r = client.get('/api/v1/apps', headers={**_token(me.id), 'X-Workspace-Id': str(ws.id)})
|
|
assert r.status_code == 200
|
|
assert {a['name'] for a in r.get_json()['apps']} == {'mine'}
|
|
|
|
# An admin, by contrast, sees everything in the workspace.
|
|
r = client.get('/api/v1/apps', headers={**_token(admin.id), 'X-Workspace-Id': str(ws.id)})
|
|
assert {a['name'] for a in r.get_json()['apps']} == {'mine', 'theirs'}
|
|
|
|
|
|
def test_create_app_stamps_workspace(app, client):
|
|
from app.services.workspace_service import WorkspaceService
|
|
from app import db
|
|
|
|
dev = _mk_user(db, 'cre_dev', 'developer')
|
|
|
|
# No context -> stamped with the default workspace.
|
|
r = client.post('/api/v1/apps', headers=_token(dev.id), json={'name': 'newapp', 'app_type': 'php'})
|
|
assert r.status_code == 201
|
|
assert r.get_json()['app']['workspace_id'] == WorkspaceService.ensure_default_workspace().id
|
|
|
|
# With a workspace context (member) -> stamped with that workspace.
|
|
admin = _mk_user(db, 'cre_admin', 'admin')
|
|
ws = WorkspaceService.create_workspace({'name': 'CreWS'}, admin.id)
|
|
WorkspaceService.add_member(ws.id, dev.id, 'member')
|
|
r = client.post('/api/v1/apps', headers={**_token(dev.id), 'X-Workspace-Id': str(ws.id)},
|
|
json={'name': 'wsapp', 'app_type': 'php'})
|
|
assert r.status_code == 201
|
|
assert r.get_json()['app']['workspace_id'] == ws.id
|
|
|
|
|
|
# ---- migration backfill (raw SQL, run against real SQLite) ----------------
|
|
|
|
def test_migration_backfill_attaches_resources_and_members(app):
|
|
import importlib.util
|
|
import os
|
|
from app import db
|
|
from app.models import Application, Server
|
|
from app.models.workspace import Workspace, WorkspaceMember
|
|
|
|
u1 = _mk_user(db, 'bf_u1', 'admin')
|
|
u2 = _mk_user(db, 'bf_u2', 'developer')
|
|
db.session.add_all([
|
|
Application(name='bf-app', app_type='php', user_id=u1.id),
|
|
Server(name='bf-srv', registered_by=u1.id),
|
|
])
|
|
db.session.commit()
|
|
|
|
# Load the migration module by path (its name starts with a digit) and run
|
|
# the backfill against the live connection — exercising the actual raw SQL.
|
|
path = os.path.join(os.path.dirname(os.path.dirname(__file__)),
|
|
'migrations', 'versions', '015_workspace_scope.py')
|
|
spec = importlib.util.spec_from_file_location('mig015', path)
|
|
mig = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mig)
|
|
|
|
mig._backfill(db.session.connection())
|
|
db.session.commit()
|
|
|
|
default = Workspace.query.filter_by(slug='default').first()
|
|
assert default is not None
|
|
assert Application.query.filter_by(name='bf-app').first().workspace_id == default.id
|
|
assert Server.query.filter_by(name='bf-srv').first().workspace_id == default.id
|
|
assert WorkspaceMember.query.filter_by(workspace_id=default.id, user_id=u1.id).first() is not None
|
|
assert WorkspaceMember.query.filter_by(workspace_id=default.id, user_id=u2.id).first() is not None
|
|
|
|
# Idempotent: a second run creates no duplicate members and doesn't error.
|
|
mig._backfill(db.session.connection())
|
|
db.session.commit()
|
|
assert WorkspaceMember.query.filter_by(workspace_id=default.id, user_id=u1.id).count() == 1
|
|
assert Workspace.query.filter_by(slug='default').count() == 1
|
|
|
|
|
|
# ---- API: servers ---------------------------------------------------------
|
|
|
|
def test_set_app_workspace_api(app, client):
|
|
from app import db
|
|
from app.services.workspace_service import WorkspaceService
|
|
from app.models import Application
|
|
|
|
owner = _mk_user(db, 'mv_owner', 'developer')
|
|
other = _mk_user(db, 'mv_other', 'developer')
|
|
admin = _mk_user(db, 'mv_admin', 'admin')
|
|
ws = WorkspaceService.create_workspace({'name': 'MoveWS'}, admin.id)
|
|
default_id = WorkspaceService.ensure_default_workspace().id
|
|
a = Application(name='movable', app_type='php', user_id=owner.id, workspace_id=default_id)
|
|
db.session.add(a)
|
|
db.session.commit()
|
|
app_id = a.id
|
|
|
|
# Owner isn't a member of the target -> 403.
|
|
r = client.put(f'/api/v1/apps/{app_id}/workspace', json={'workspace_id': ws.id}, headers=_token(owner.id))
|
|
assert r.status_code == 403
|
|
|
|
# Once a member, the move succeeds.
|
|
WorkspaceService.add_member(ws.id, owner.id, 'member')
|
|
r = client.put(f'/api/v1/apps/{app_id}/workspace', json={'workspace_id': ws.id}, headers=_token(owner.id))
|
|
assert r.status_code == 200 and r.get_json()['app']['workspace_id'] == ws.id
|
|
|
|
# A null target moves it back to Default.
|
|
r = client.put(f'/api/v1/apps/{app_id}/workspace', json={'workspace_id': None}, headers=_token(owner.id))
|
|
assert r.status_code == 200 and r.get_json()['app']['workspace_id'] == default_id
|
|
|
|
# A non-owner non-admin can't reassign it.
|
|
r = client.put(f'/api/v1/apps/{app_id}/workspace', json={'workspace_id': None}, headers=_token(other.id))
|
|
assert r.status_code == 403
|
|
|
|
# Unknown target workspace -> 404 (admin passes the ownership gate).
|
|
r = client.put(f'/api/v1/apps/{app_id}/workspace', json={'workspace_id': 999999}, headers=_token(admin.id))
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_set_server_workspace_api(app, client):
|
|
from app import db
|
|
from app.services.workspace_service import WorkspaceService
|
|
from app.models import Server
|
|
|
|
admin = _mk_user(db, 'sw_admin', 'admin')
|
|
dev = _mk_user(db, 'sw_dev', 'developer')
|
|
viewer = _mk_user(db, 'sw_viewer', 'viewer')
|
|
ws = WorkspaceService.create_workspace({'name': 'SrvWS'}, admin.id)
|
|
s = Server(name='srv-move', registered_by=admin.id,
|
|
workspace_id=WorkspaceService.ensure_default_workspace().id)
|
|
db.session.add(s)
|
|
db.session.commit()
|
|
sid = s.id
|
|
|
|
# A developer who isn't a member of the target -> 403.
|
|
r = client.put(f'/api/v1/servers/{sid}/workspace', json={'workspace_id': ws.id}, headers=_token(dev.id))
|
|
assert r.status_code == 403
|
|
|
|
# Admin can move it (admin bypass).
|
|
r = client.put(f'/api/v1/servers/{sid}/workspace', json={'workspace_id': ws.id}, headers=_token(admin.id))
|
|
assert r.status_code == 200 and r.get_json()['server']['workspace_id'] == ws.id
|
|
|
|
# A viewer is blocked by @developer_required.
|
|
r = client.put(f'/api/v1/servers/{sid}/workspace', json={'workspace_id': None}, headers=_token(viewer.id))
|
|
assert r.status_code == 403
|
|
|
|
|
|
def test_servers_list_global_without_context(app, client):
|
|
from app import db
|
|
from app.models import Server
|
|
|
|
admin = _mk_user(db, 'srv_admin', 'admin')
|
|
dev = _mk_user(db, 'srv_dev', 'developer')
|
|
db.session.add(Server(name='srv-a', registered_by=admin.id))
|
|
db.session.commit()
|
|
|
|
# Servers are global today: a non-admin with no workspace context still sees them.
|
|
r = client.get('/api/v1/servers', headers=_token(dev.id))
|
|
assert r.status_code == 200
|
|
assert any(s['name'] == 'srv-a' for s in r.get_json())
|
|
|
|
|
|
def test_wordpress_sites_scoping_api(app, client, wp_extension):
|
|
from app import db
|
|
from app.services.workspace_service import WorkspaceService
|
|
from app.models import Application, WordPressSite
|
|
|
|
admin = _mk_user(db, 'wp_admin', 'admin')
|
|
ws_a = WorkspaceService.create_workspace({'name': 'WP-A'}, admin.id)
|
|
ws_b = WorkspaceService.create_workspace({'name': 'WP-B'}, admin.id)
|
|
|
|
def mk_site(name, ws_id):
|
|
a = Application(name=name, app_type='wordpress', user_id=admin.id, workspace_id=ws_id)
|
|
db.session.add(a)
|
|
db.session.commit()
|
|
db.session.add(WordPressSite(application_id=a.id, is_production=True))
|
|
db.session.commit()
|
|
|
|
mk_site('site-a', ws_a.id)
|
|
mk_site('site-b', ws_b.id)
|
|
|
|
# No context: the WP hub is global -> both sites.
|
|
r = client.get('/api/v1/wordpress/sites', headers=_token(admin.id))
|
|
assert r.status_code == 200
|
|
assert {s['name'] for s in r.get_json()['sites']} == {'site-a', 'site-b'}
|
|
|
|
# Scoped to workspace A (via the site's parent application) -> only site-a.
|
|
r = client.get('/api/v1/wordpress/sites', headers={**_token(admin.id), 'X-Workspace-Id': str(ws_a.id)})
|
|
assert r.status_code == 200
|
|
assert {s['name'] for s in r.get_json()['sites']} == {'site-a'}
|
|
|
|
|
|
# ---- API: domains (app-children, scoped through parent Application) --------
|
|
|
|
def test_domains_scoping_api(app, client):
|
|
from app import db
|
|
from app.services.workspace_service import WorkspaceService
|
|
from app.services.resource_grant_service import ResourceGrantService
|
|
from app.models import Application, Domain
|
|
|
|
admin = _mk_user(db, 'dom_admin', 'admin')
|
|
dev = _mk_user(db, 'dom_dev', 'developer')
|
|
other = _mk_user(db, 'dom_other', 'developer')
|
|
ws_a = WorkspaceService.create_workspace({'name': 'DomA'}, admin.id)
|
|
ws_b = WorkspaceService.create_workspace({'name': 'DomB'}, admin.id)
|
|
WorkspaceService.add_member(ws_a.id, dev.id, 'member')
|
|
|
|
def mk(name, owner_id, ws_id, domain):
|
|
a = Application(name=name, app_type='docker', user_id=owner_id, workspace_id=ws_id)
|
|
db.session.add(a)
|
|
db.session.commit()
|
|
db.session.add(Domain(name=domain, application_id=a.id))
|
|
db.session.commit()
|
|
return a
|
|
|
|
mk('dom-a', dev.id, ws_a.id, 'a.example.com')
|
|
mk('dom-b', dev.id, ws_b.id, 'b.example.com')
|
|
shared = mk('dom-shared', other.id, ws_a.id, 'shared.example.com')
|
|
|
|
# No context: dev sees its own apps' domains across workspaces, not other's.
|
|
r = client.get('/api/v1/domains', headers=_token(dev.id))
|
|
assert r.status_code == 200
|
|
assert {d['name'] for d in r.get_json()['domains']} == {'a.example.com', 'b.example.com'}
|
|
|
|
# Scoped to ws_a: only domains of dev's apps in ws_a.
|
|
r = client.get('/api/v1/domains', headers={**_token(dev.id), 'X-Workspace-Id': str(ws_a.id)})
|
|
assert {d['name'] for d in r.get_json()['domains']} == {'a.example.com'}
|
|
|
|
# A grant on another user's app surfaces that app's domain too (and it's in ws_a).
|
|
ResourceGrantService.grant(dev.id, 'application', shared.id, granted_by=admin.id, role='viewer')
|
|
r = client.get('/api/v1/domains', headers={**_token(dev.id), 'X-Workspace-Id': str(ws_a.id)})
|
|
assert {d['name'] for d in r.get_json()['domains']} == {'a.example.com', 'shared.example.com'}
|
|
|
|
# Admin with no context sees every domain.
|
|
r = client.get('/api/v1/domains', headers=_token(admin.id))
|
|
got = {d['name'] for d in r.get_json()['domains']}
|
|
assert {'a.example.com', 'b.example.com', 'shared.example.com'} <= got
|
|
|
|
|
|
# ---- API: docker databases (app-children) ---------------------------------
|
|
|
|
def test_docker_databases_scoping_api(app, client, monkeypatch):
|
|
from app import db
|
|
from app.services.workspace_service import WorkspaceService
|
|
from app.services.database_service import DatabaseService
|
|
from app.models import Application
|
|
|
|
admin = _mk_user(db, 'ddb_admin', 'admin')
|
|
dev = _mk_user(db, 'ddb_dev', 'developer')
|
|
ws_a = WorkspaceService.create_workspace({'name': 'DdbA'}, admin.id)
|
|
ws_b = WorkspaceService.create_workspace({'name': 'DdbB'}, admin.id)
|
|
WorkspaceService.add_member(ws_a.id, dev.id, 'member')
|
|
|
|
db.session.add_all([
|
|
Application(name='ddb-a', app_type='docker', user_id=dev.id, workspace_id=ws_a.id, root_path='/srv/a'),
|
|
Application(name='ddb-b', app_type='docker', user_id=dev.id, workspace_id=ws_b.id, root_path='/srv/b'),
|
|
])
|
|
db.session.commit()
|
|
|
|
# Stub the compose/.env read so each app yields one database tagged by app name.
|
|
monkeypatch.setattr(DatabaseService, 'get_app_database_info',
|
|
lambda name, root: [{'database': name}])
|
|
|
|
# No context: dev sees both its docker apps' databases.
|
|
r = client.get('/api/v1/databases/docker/databases', headers=_token(dev.id))
|
|
assert r.status_code == 200
|
|
assert {d['app_name'] for d in r.get_json()['databases']} == {'ddb-a', 'ddb-b'}
|
|
|
|
# Scoped to ws_a -> only ddb-a's database.
|
|
r = client.get('/api/v1/databases/docker/databases',
|
|
headers={**_token(dev.id), 'X-Workspace-Id': str(ws_a.id)})
|
|
assert {d['app_name'] for d in r.get_json()['databases']} == {'ddb-a'}
|
|
|
|
|
|
# ---- role reconciliation (#33) --------------------------------------------
|
|
|
|
def test_effective_role_reconciliation(app):
|
|
from app import db
|
|
from app.services.workspace_service import WorkspaceService
|
|
|
|
sysadmin = _mk_user(db, 'er_admin', 'admin')
|
|
dev_viewer = _mk_user(db, 'er_devv', 'developer')
|
|
dev_member = _mk_user(db, 'er_devm', 'developer')
|
|
global_viewer = _mk_user(db, 'er_gv', 'viewer')
|
|
ws = WorkspaceService.create_workspace({'name': 'ER'}, sysadmin.id)
|
|
WorkspaceService.add_member(ws.id, dev_viewer.id, 'viewer')
|
|
WorkspaceService.add_member(ws.id, dev_member.id, 'member')
|
|
WorkspaceService.add_member(ws.id, global_viewer.id, 'owner')
|
|
|
|
# No context -> global role unchanged.
|
|
assert WorkspaceService.effective_role(dev_member, None) == 'developer'
|
|
assert WorkspaceService.can_write_in_workspace(dev_member, None) is True
|
|
|
|
# A 'viewer' membership caps a global developer to viewer (read-only).
|
|
assert WorkspaceService.effective_role(dev_viewer, ws.id) == 'viewer'
|
|
assert WorkspaceService.can_write_in_workspace(dev_viewer, ws.id) is False
|
|
|
|
# A 'member' membership does not cap (stays developer).
|
|
assert WorkspaceService.effective_role(dev_member, ws.id) == 'developer'
|
|
assert WorkspaceService.can_write_in_workspace(dev_member, ws.id) is True
|
|
|
|
# Narrow-only: a workspace role NEVER elevates. A global viewer who is even an
|
|
# 'owner' member is still effectively a viewer.
|
|
assert WorkspaceService.effective_role(global_viewer, ws.id) == 'viewer'
|
|
assert WorkspaceService.can_write_in_workspace(global_viewer, ws.id) is False
|
|
|
|
# A system admin is never capped by a workspace membership.
|
|
assert WorkspaceService.effective_role(sysadmin, ws.id) == 'admin'
|
|
assert WorkspaceService.can_write_in_workspace(sysadmin, ws.id) is True
|
|
|
|
|
|
def test_create_app_viewer_blocked_in_workspace(app, client):
|
|
from app import db
|
|
from app.services.workspace_service import WorkspaceService
|
|
|
|
admin = _mk_user(db, 'vb_admin', 'admin')
|
|
dev = _mk_user(db, 'vb_dev', 'developer')
|
|
ws = WorkspaceService.create_workspace({'name': 'VB'}, admin.id)
|
|
WorkspaceService.add_member(ws.id, dev.id, 'viewer')
|
|
|
|
# A 'viewer' member can't create in that workspace.
|
|
r = client.post('/api/v1/apps', headers={**_token(dev.id), 'X-Workspace-Id': str(ws.id)},
|
|
json={'name': 'vb-nope', 'app_type': 'php'})
|
|
assert r.status_code == 403
|
|
|
|
# With no workspace context the same user still creates (the cap is
|
|
# workspace-scoped and narrow-only — no-context behavior is unchanged).
|
|
r = client.post('/api/v1/apps', headers=_token(dev.id), json={'name': 'vb-yes', 'app_type': 'php'})
|
|
assert r.status_code == 201
|
|
app_id = r.get_json()['app']['id']
|
|
|
|
# And the app can't be moved INTO the workspace they're a viewer of.
|
|
r = client.put(f'/api/v1/apps/{app_id}/workspace', json={'workspace_id': ws.id}, headers=_token(dev.id))
|
|
assert r.status_code == 403
|
|
|
|
|
|
def test_list_workspaces_surfaces_effective_role(app, client):
|
|
from app import db
|
|
from app.services.workspace_service import WorkspaceService
|
|
|
|
admin = _mk_user(db, 'lw_admin', 'admin')
|
|
dev = _mk_user(db, 'lw_dev', 'developer')
|
|
ws = WorkspaceService.create_workspace({'name': 'LW'}, admin.id)
|
|
WorkspaceService.add_member(ws.id, dev.id, 'viewer')
|
|
|
|
r = client.get('/api/v1/workspaces/', headers=_token(dev.id))
|
|
assert r.status_code == 200
|
|
rows = {w['name']: w for w in r.get_json()['workspaces']}
|
|
assert rows['LW']['my_role'] == 'viewer'
|
|
assert rows['LW']['my_effective_role'] == 'viewer'
|
|
|
|
|
|
def test_create_workspace_requires_admin(app, client):
|
|
"""GHSA-r4q7-x795-vr4j: POST /api/v1/workspaces/ was gated only by
|
|
@jwt_required(), so a read-only viewer could create a workspace, become its
|
|
owner, then add arbitrary members and mint workspace API keys. It now
|
|
carries @admin_required like every other panel-wide create."""
|
|
from app import db
|
|
|
|
for role in ('viewer', 'developer'):
|
|
u = _mk_user(db, f'cw_{role}', role)
|
|
r = client.post('/api/v1/workspaces/', headers=_token(u.id),
|
|
json={'name': f'cw-{role}', 'slug': f'cw-{role}'})
|
|
assert r.status_code == 403, f'{role} reached workspace creation: {r.status_code}'
|
|
|
|
admin = _mk_user(db, 'cw_admin', 'admin')
|
|
r = client.post('/api/v1/workspaces/', headers=_token(admin.id),
|
|
json={'name': 'cw-ok', 'slug': 'cw-ok'})
|
|
assert r.status_code == 201
|