mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-28 23:57:58 +09:00
feat(workspaces): per-resource access grants — per-site ACL backend (#33)
An owner/admin can share a specific application (transitively its WordPress site / DB / domains) with a specific user who doesn't own it. Access-level for now; per-grant view/edit roles are reserved via a role column. - New ResourceGrant model (Alembic 016, unique per user x resource) + ResourceGrantService (grant/revoke/list/user_has_grant/granted_ids). - Enforcement at the cleanest seams: scope_query gained grant_resource_type so granted apps appear in the apps list (own OR granted, then workspace-narrowed); get_app and the WordPress _owner_or_admin_app helper became grant-aware, so one change opens every WP per-site route to a grantee. - Grant-management API (GET/POST/DELETE /apps/<id>/grants, owner-or-admin; can't grant to the owner; revoke is resource-scoped so you can't revoke another app's grant by id). Uses user.id (int), not the stringified get_jwt_identity(). Verified: 4 new ACL tests (visibility, get_app, WP route access, management perms + idempotency + revoke), full suite 127 passed, end-to-end alembic upgrade to 016. Deferred: a Sharing UI (next), and broadening grant-access to the generic app operate endpoints (start/stop/update stay owner-or-admin; delete stays owner-only). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
35c7a73d8c
commit
d9a5ec7e92
9 files changed
+378
-23
No files matched your search
+71
-4
@@ -318,7 +318,8 @@ def get_apps():
|
||||
ws_id = WorkspaceService.resolve_workspace_id(
|
||||
user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id'))
|
||||
query = WorkspaceService.scope_query(Application.query, Application, user,
|
||||
workspace_id=ws_id, owner_attr='user_id')
|
||||
workspace_id=ws_id, owner_attr='user_id',
|
||||
grant_resource_type='application')
|
||||
|
||||
if environment_filter:
|
||||
query = query.filter_by(environment_type=environment_filter)
|
||||
@@ -366,23 +367,89 @@ def set_app_workspace(app_id):
|
||||
return jsonify({'message': 'Workspace updated', 'app': app.to_dict()}), 200
|
||||
|
||||
|
||||
def _can_access_app(user, app):
|
||||
"""True if the user owns the app, is admin, or was granted access (#33 ACL).
|
||||
Uses user.id (int) — get_jwt_identity() is the stringified token id."""
|
||||
if user.is_admin or app.user_id == user.id:
|
||||
return True
|
||||
from app.services.resource_grant_service import ResourceGrantService
|
||||
return ResourceGrantService.user_has_grant(user.id, 'application', app.id)
|
||||
|
||||
|
||||
@apps_bp.route('/<int:app_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_app(app_id):
|
||||
current_user_id = get_jwt_identity()
|
||||
user = User.query.get(current_user_id)
|
||||
user = User.query.get(get_jwt_identity())
|
||||
app = Application.query.get(app_id)
|
||||
|
||||
if not app:
|
||||
return jsonify({'error': 'Application not found'}), 404
|
||||
|
||||
if user.role != 'admin' and app.user_id != current_user_id:
|
||||
if not _can_access_app(user, app):
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
|
||||
# Single app requests include linked app info by default
|
||||
return jsonify({'app': _attach_deploy_config(app.to_dict(include_linked=True))}), 200
|
||||
|
||||
|
||||
# ---- Per-resource access grants (#33 per-site ACL): share an app with a user ----
|
||||
|
||||
@apps_bp.route('/<int:app_id>/grants', methods=['GET'])
|
||||
@jwt_required()
|
||||
def list_app_grants(app_id):
|
||||
"""List who has been granted access to this app (owner-or-admin)."""
|
||||
from app.services.resource_grant_service import ResourceGrantService
|
||||
user = User.query.get(get_jwt_identity())
|
||||
app = Application.query.get(app_id)
|
||||
if not app:
|
||||
return jsonify({'error': 'Application not found'}), 404
|
||||
if user.role != 'admin' and app.user_id != user.id:
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
grants = ResourceGrantService.list_for_resource('application', app.id)
|
||||
return jsonify({'grants': [g.to_dict() for g in grants]}), 200
|
||||
|
||||
|
||||
@apps_bp.route('/<int:app_id>/grants', methods=['POST'])
|
||||
@jwt_required()
|
||||
def grant_app_access(app_id):
|
||||
"""Grant a user access to this app (owner-or-admin). Body: {user_id, role?}."""
|
||||
from app.services.resource_grant_service import ResourceGrantService
|
||||
user = User.query.get(get_jwt_identity())
|
||||
app = Application.query.get(app_id)
|
||||
if not app:
|
||||
return jsonify({'error': 'Application not found'}), 404
|
||||
if user.role != 'admin' and app.user_id != user.id:
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
data = request.get_json() or {}
|
||||
grantee_id = data.get('user_id')
|
||||
if not grantee_id:
|
||||
return jsonify({'error': 'user_id is required'}), 400
|
||||
grantee = User.query.get(grantee_id)
|
||||
if not grantee:
|
||||
return jsonify({'error': 'User not found'}), 404
|
||||
if grantee.id == app.user_id:
|
||||
return jsonify({'error': 'The owner already has access'}), 400
|
||||
grant = ResourceGrantService.grant(user_id=grantee.id, resource_type='application',
|
||||
resource_id=app.id, granted_by=user.id,
|
||||
role=data.get('role') or 'editor')
|
||||
return jsonify({'grant': grant.to_dict()}), 201
|
||||
|
||||
|
||||
@apps_bp.route('/<int:app_id>/grants/<int:grant_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def revoke_app_access(app_id, grant_id):
|
||||
"""Revoke a grant on this app (owner-or-admin)."""
|
||||
from app.services.resource_grant_service import ResourceGrantService
|
||||
user = User.query.get(get_jwt_identity())
|
||||
app = Application.query.get(app_id)
|
||||
if not app:
|
||||
return jsonify({'error': 'Application not found'}), 404
|
||||
if user.role != 'admin' and app.user_id != user.id:
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
ok = ResourceGrantService.revoke(grant_id, resource_type='application', resource_id=app.id)
|
||||
return jsonify({'success': ok}), (200 if ok else 404)
|
||||
|
||||
|
||||
@apps_bp.route('/from-repository', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_app_from_repository():
|
||||
|
||||
@@ -558,15 +558,18 @@ def scan_site_vulnerabilities(app_id):
|
||||
# ---- Per-site security depth (#30): file integrity, WP_DEBUG, WP-Cron ----
|
||||
|
||||
def _owner_or_admin_app(app_id):
|
||||
"""Resolve an app and enforce the owner-or-admin read guard. Returns
|
||||
(app, None) or (None, (response, status))."""
|
||||
current_user_id = get_jwt_identity()
|
||||
user = User.query.get(current_user_id)
|
||||
"""Resolve an app and enforce the access guard: owner, admin, or a user the
|
||||
app was shared with via a per-resource grant (#33). Returns (app, None) or
|
||||
(None, (response, status))."""
|
||||
user = User.query.get(get_jwt_identity())
|
||||
app = _resolve_app(app_id)
|
||||
if not app:
|
||||
return None, (jsonify({'error': 'Application not found'}), 404)
|
||||
if user.role != 'admin' and app.user_id != current_user_id:
|
||||
return None, (jsonify({'error': 'Access denied'}), 403)
|
||||
# user.id (int), not the stringified token id; honor per-resource grants.
|
||||
if not user.is_admin and app.user_id != user.id:
|
||||
from app.services.resource_grant_service import ResourceGrantService
|
||||
if not ResourceGrantService.user_has_grant(user.id, 'application', app.id):
|
||||
return None, (jsonify({'error': 'Access denied'}), 403)
|
||||
return app, None
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from app.models.invitation import Invitation
|
||||
from app.models.metric_alert import ServerAlertThreshold, MetricAlert
|
||||
from app.models.agent_plugin import AgentPlugin, AgentPluginInstall
|
||||
from app.models.server_template import ServerTemplate, ServerTemplateAssignment
|
||||
from app.models.workspace import Workspace, WorkspaceMember, WorkspaceApiKey
|
||||
from app.models.workspace import Workspace, WorkspaceMember, WorkspaceApiKey, ResourceGrant
|
||||
from app.models.dns_zone import DNSZone, DNSRecord
|
||||
from app.models.status_page import StatusPage, StatusComponent, HealthCheck, StatusIncident, StatusIncidentUpdate
|
||||
from app.models.cloud_server import CloudProvider, CloudServer, CloudSnapshot
|
||||
@@ -48,7 +48,7 @@ __all__ = [
|
||||
'ServerAlertThreshold', 'MetricAlert',
|
||||
'AgentPlugin', 'AgentPluginInstall',
|
||||
'ServerTemplate', 'ServerTemplateAssignment',
|
||||
'Workspace', 'WorkspaceMember', 'WorkspaceApiKey',
|
||||
'Workspace', 'WorkspaceMember', 'WorkspaceApiKey', 'ResourceGrant',
|
||||
'DNSZone', 'DNSRecord',
|
||||
'StatusPage', 'StatusComponent', 'HealthCheck', 'StatusIncident', 'StatusIncidentUpdate',
|
||||
'CloudProvider', 'CloudServer', 'CloudSnapshot',
|
||||
|
||||
@@ -139,3 +139,42 @@ class WorkspaceApiKey(db.Model):
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'last_used_at': self.last_used_at.isoformat() if self.last_used_at else None,
|
||||
}
|
||||
|
||||
|
||||
class ResourceGrant(db.Model):
|
||||
"""Per-resource access grant (#33 — per-site ACL): grants a user access to a
|
||||
specific resource they don't own (e.g. an application and, transitively, its
|
||||
WordPress site / databases / domains).
|
||||
|
||||
Access-level for now — a granted user can see and operate the resource like an
|
||||
owner. Per-grant view/edit roles are a future refinement; the `role` column is
|
||||
reserved for it but not yet enforced.
|
||||
"""
|
||||
__tablename__ = 'resource_grants'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
resource_type = db.Column(db.String(32), nullable=False) # 'application'
|
||||
resource_id = db.Column(db.Integer, nullable=False, index=True)
|
||||
role = db.Column(db.String(16), default='editor') # reserved for view/edit
|
||||
granted_by = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'resource_type', 'resource_id', name='uq_resource_grant'),
|
||||
)
|
||||
|
||||
user = db.relationship('User', foreign_keys=[user_id])
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'user_id': self.user_id,
|
||||
'username': self.user.username if self.user else None,
|
||||
'email': self.user.email if self.user else None,
|
||||
'resource_type': self.resource_type,
|
||||
'resource_id': self.resource_id,
|
||||
'role': self.role,
|
||||
'granted_by': self.granted_by,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Per-resource access grants (#33 — per-site ACL).
|
||||
|
||||
A grant gives a user access to a specific resource (currently an application,
|
||||
which transitively covers its WordPress site / databases / domains) without
|
||||
owning it. This service is the single seam for creating, revoking, querying, and
|
||||
checking grants; enforcement lives in WorkspaceService.scope_query (lists) and
|
||||
the per-resource access helpers in the API layer.
|
||||
"""
|
||||
|
||||
RESOURCE_APPLICATION = 'application'
|
||||
|
||||
|
||||
class ResourceGrantService:
|
||||
|
||||
@staticmethod
|
||||
def granted_ids(user_id, resource_type):
|
||||
"""The resource_ids of `resource_type` this user has been granted."""
|
||||
from app.models.workspace import ResourceGrant
|
||||
rows = (ResourceGrant.query
|
||||
.with_entities(ResourceGrant.resource_id)
|
||||
.filter_by(user_id=user_id, resource_type=resource_type)
|
||||
.all())
|
||||
return [r[0] for r in rows]
|
||||
|
||||
@staticmethod
|
||||
def user_has_grant(user_id, resource_type, resource_id):
|
||||
from app.models.workspace import ResourceGrant
|
||||
return ResourceGrant.query.filter_by(
|
||||
user_id=user_id, resource_type=resource_type, resource_id=resource_id
|
||||
).first() is not None
|
||||
|
||||
@staticmethod
|
||||
def list_for_resource(resource_type, resource_id):
|
||||
from app.models.workspace import ResourceGrant
|
||||
return ResourceGrant.query.filter_by(
|
||||
resource_type=resource_type, resource_id=resource_id
|
||||
).all()
|
||||
|
||||
@staticmethod
|
||||
def grant(user_id, resource_type, resource_id, granted_by=None, role='editor'):
|
||||
"""Create (or update) a grant. Idempotent on (user, resource)."""
|
||||
from app import db
|
||||
from app.models.workspace import ResourceGrant
|
||||
existing = ResourceGrant.query.filter_by(
|
||||
user_id=user_id, resource_type=resource_type, resource_id=resource_id
|
||||
).first()
|
||||
if existing:
|
||||
existing.role = role
|
||||
db.session.commit()
|
||||
return existing
|
||||
row = ResourceGrant(user_id=user_id, resource_type=resource_type,
|
||||
resource_id=resource_id, role=role, granted_by=granted_by)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
return row
|
||||
|
||||
@staticmethod
|
||||
def revoke(grant_id, resource_type=None, resource_id=None):
|
||||
"""Delete a grant by id. When resource_type/resource_id are given, the
|
||||
grant must belong to that resource (so a caller can't revoke another
|
||||
resource's grant by id)."""
|
||||
from app import db
|
||||
from app.models.workspace import ResourceGrant
|
||||
row = ResourceGrant.query.get(grant_id)
|
||||
if row is None:
|
||||
return False
|
||||
if resource_type is not None and (row.resource_type != resource_type or row.resource_id != resource_id):
|
||||
return False
|
||||
db.session.delete(row)
|
||||
db.session.commit()
|
||||
return True
|
||||
@@ -73,23 +73,30 @@ class WorkspaceService:
|
||||
return ws_id
|
||||
|
||||
@staticmethod
|
||||
def scope_query(query, model, user, workspace_id=None, owner_attr=None):
|
||||
"""Apply scoping to a resource list query. Critically, this can only ever
|
||||
NARROW what a user sees — it never broadens access — so activating a
|
||||
workspace can't reveal another user's resources.
|
||||
def scope_query(query, model, user, workspace_id=None, owner_attr=None, grant_resource_type=None):
|
||||
"""Apply scoping to a resource list query.
|
||||
|
||||
- non-admin + owner_attr -> always restricted to the user's own rows
|
||||
(preserves the existing per-user ownership model, e.g. applications).
|
||||
A workspace context then narrows *within* those rows.
|
||||
- non-admin + owner_attr -> restricted to the user's own rows, PLUS any
|
||||
rows explicitly shared with them via a per-resource grant (#33) when
|
||||
grant_resource_type is given (e.g. 'application'). A workspace context
|
||||
then narrows *within* that set.
|
||||
- admin, or a global resource (owner_attr=None, e.g. servers) -> no owner
|
||||
filter; today's behavior (admin sees all; servers are global).
|
||||
- workspace context active -> additionally filter to that workspace.
|
||||
|
||||
Broader "every member sees every resource in the workspace" visibility is
|
||||
deliberately NOT done here — that needs the workspace-role/ACL step.
|
||||
It still only ever NARROWS the global/admin view; for non-admins it widens
|
||||
ONLY by explicit grants the user was given, never by workspace membership
|
||||
alone.
|
||||
"""
|
||||
if owner_attr and not user.is_admin:
|
||||
query = query.filter(getattr(model, owner_attr) == user.id)
|
||||
own = getattr(model, owner_attr) == user.id
|
||||
if grant_resource_type:
|
||||
from app import db
|
||||
from app.services.resource_grant_service import ResourceGrantService
|
||||
granted = ResourceGrantService.granted_ids(user.id, grant_resource_type)
|
||||
query = query.filter(db.or_(own, model.id.in_(granted))) if granted else query.filter(own)
|
||||
else:
|
||||
query = query.filter(own)
|
||||
if workspace_id is not None:
|
||||
query = query.filter(model.workspace_id == workspace_id)
|
||||
return query
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Per-resource access grants (#33 — per-site ACL).
|
||||
|
||||
Adds:
|
||||
- table resource_grants (one row per user-x-resource grant; resource_type +
|
||||
resource_id identify the shared resource, e.g. an application)
|
||||
|
||||
Idempotent: MigrationService._fix_missing_columns runs db.create_all() on boot
|
||||
before Alembic (creating the table), so guard on the live schema like 009..015.
|
||||
|
||||
Revision ID: 016_resource_grants
|
||||
Revises: 015_workspace_scope
|
||||
Create Date: 2026-06-01
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '016_resource_grants'
|
||||
down_revision = '015_workspace_scope'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'resource_grants' not in inspector.get_table_names():
|
||||
op.create_table(
|
||||
'resource_grants',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False, index=True),
|
||||
sa.Column('resource_type', sa.String(length=32), nullable=False),
|
||||
sa.Column('resource_id', sa.Integer(), nullable=False, index=True),
|
||||
sa.Column('role', sa.String(length=16)),
|
||||
sa.Column('granted_by', sa.Integer(), sa.ForeignKey('users.id')),
|
||||
sa.Column('created_at', sa.DateTime()),
|
||||
sa.UniqueConstraint('user_id', 'resource_type', 'resource_id', name='uq_resource_grant'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'resource_grants' in inspector.get_table_names():
|
||||
op.drop_table('resource_grants')
|
||||
@@ -0,0 +1,123 @@
|
||||
"""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_grant_enables_wordpress_per_site_routes(app, client):
|
||||
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
|
||||
@@ -291,7 +291,7 @@ Highest-effort, broadest surface — but by now the `WordPressSite` is a first-c
|
||||
- **Done when:** Each site exposes scoped SFTP credentials.
|
||||
- **Assessment / why deferred:** The roadmap's host-sshd-chroot approach doesn't fit the Docker model — a managed site's files live in the `wordpress_html` **named volume**, not a host path, so a host-level chroot would target the wrong place. The **Docker-correct** design is a per-site **SFTP sidecar container** (e.g. `atmoz/sftp`) mounting `wordpress_html`, injected into the compose stack exactly like #23's redis (`_ensure_*_in_stack` + additive `compose up -d`), with a generated credential surfaced once and a new mapped port (via the template's port-allocation). That is genuinely implementable on the proven pattern — but it (a) opens a **publicly-exposed, security-sensitive file-access surface** that should be firewalled and that I cannot runtime-verify from the dev host, and (b) consumes a port + a container per site. Shipping security-sensitive infra blind violates the quality bar, so this is deferred for a focused pass on a real Docker host (where the sidecar, chroot perms, and firewall can be exercised). No code shipped.
|
||||
|
||||
### #33 — Agency scale (workspaces, RBAC, white-label, reports) `[XL]` 🟡 — 🟡 In progress (reports slice ✅ + `workspace_id` scoping foundation ✅ shipped; broad route adoption + per-site ACL + workspace↔User role reconciliation + server-side branding + frontend workspace selector still deferred)
|
||||
### #33 — Agency scale (workspaces, RBAC, white-label, reports) `[XL]` 🟡 — 🟡 In progress (✅ reports · workspace_id foundation · selector · brand color · resource reassignment · WP-hub scoping · **per-site ACL backend**; remaining: ACL sharing UI, broad operate-endpoint adoption, workspace↔User role reconciliation)
|
||||
- **Today:** No `workspace_id` FK on `Application`/`WordPressSite`/`Server`; permissions are global with no per-site ACL; white-label lives in `localStorage` and never reads `Workspace.logo_url`/`primary_color`.
|
||||
- **Do:** Add `workspace_id` FKs + per-site ACL rows + environment-tier RBAC in promote/sync/bulk endpoints; server-side branding scoped to workspace; monthly client reports aggregating uptime/updates/backups/security.
|
||||
- **Reuse:** `workspace_service`, `permission_service`, `audit_service`.
|
||||
@@ -303,6 +303,7 @@ Highest-effort, broadest surface — but by now the `WordPressSite` is a first-c
|
||||
- **Landed (workspace-scoped brand color):** the visible payoff — selecting a client workspace now recolors the whole panel to that client's brand. A workspace's `primary_color` (already on the model; now editable via a color picker added to the Workspaces create modal) is mirrored by the `WorkspaceSwitcher` into a `workspace_accent` localStorage key, and `ThemeContext` applies it via the existing `applyAccentToDOM` with **precedence over the user's personal accent** (`workspace_accent || accentColor`) — recoloring the logo gradient, buttons, and highlights. Deterministic (no effect-ordering races) because switching reloads and the value is read once on mount; cleared on logout and on a stale selection; reverts to the user accent on "All workspaces". Default color is the app accent, so a workspace is unbranded unless a custom color is chosen. Verified: frontend lint-clean (no new warnings) + production build. *(Deferred: per-workspace logo swap in the sidebar brand — the accent recolor is the high-impact piece and the default logo already recolors with the accent.)*
|
||||
- **Landed (resource reassignment):** completes the organizing loop — existing resources were stuck wherever the backfill put them (Default); now they can be moved into client workspaces. New endpoints `PUT /apps/<id>/workspace` (owner-or-admin on the app) and `PUT /servers/<id>/workspace` (developer+), both requiring the **target** be a workspace the caller can access (member or admin); a null target moves a resource back to Default. UI: an **Apps** management modal on each Workspaces card (mirrors the Members modal) listing the workspace's apps with move-in/move-out — it fetches with `allWorkspaces` (an empty `X-Workspace-Id` header) so it can see apps in other workspaces to move them in, sidestepping the ambient scope. **Fixed a real bug** caught by tests: the ownership check compared `app.user_id` to the *stringified* `get_jwt_identity()` (int≠str → spurious 403); now uses `user.id`. Verified: backend `py_compile`, **2 new endpoint tests** (ownership + target-membership + null→Default + role gates), full suite **122 passed**, frontend lint-clean + production build. The management modal now handles **both apps and servers** (a "Resources" modal on each Workspaces card), each with move-in/move-out; servers fetch with the same `allWorkspaces` header bypass.
|
||||
- **Landed (WordPress hub workspace scoping):** the active-workspace switcher now also filters the **WordPress sites hub**, not just apps/servers (it was the inconsistent gap). WP sites carry no `workspace_id` of their own, so `WordPressService.get_sites(workspace_id=…)` scopes **join-based** through the site's parent `Application.workspace_id`; `list_sites` resolves the active workspace and passes it. The hub is global today, so with no workspace context behavior is unchanged (additive, narrow-only). **No frontend change** — the WP hub already fetches through the header-injecting `api.request`, so it auto-scopes on the ambient `X-Workspace-Id`. Verified: backend `py_compile`, a new API test (global vs workspace-scoped), full suite **123 passed**. *(Follow-on for broad adoption: databases/domains are murkier — server-level vs app-child — and need their own analysis before scoping.)*
|
||||
- **Landed (per-site ACL — backend):** per-resource **sharing** — an owner/admin can grant a specific user access to a specific application (transitively its WordPress site / DB / domains) without that user owning it (access-level; per-grant view/edit roles reserved for later via a `role` column). New `ResourceGrant` model (Alembic **016**, unique per user×resource) + `ResourceGrantService` (grant/revoke/list/`user_has_grant`/`granted_ids`). **Enforcement at the cleanest seams:** `WorkspaceService.scope_query` gained `grant_resource_type` so granted apps appear in the apps list (own **OR** granted, then workspace-narrowed); `get_app` and — crucially — the WordPress `_owner_or_admin_app` helper became grant-aware, so **one change opens every WP per-site route** to a grantee. Grant-management API on `apps.py` (`GET/POST/DELETE /apps/<id>/grants`, owner-or-admin; can't grant to the owner; revoke is resource-scoped so you can't revoke another app's grant by id). Also uses `user.id` (int) not the stringified `get_jwt_identity()`. Verified: **4 new ACL tests** (visibility via list, get_app, WP route access, management perms + idempotency + revoke), full suite **127 passed**, end-to-end `alembic upgrade` to `016`. *(Deferred: a **Sharing UI** (next), and broadening grant-access to the generic app **operate** endpoints — start/stop/update — which stay owner-or-admin for now; delete stays owner-only by design.)*
|
||||
|
||||
### #34 — Horizontal scaling `[XL]` ❌ — ⏸️ Deferred (XL + blocked on the same per-site reverse-proxy/upstream layer as #21)
|
||||
- **Today:** `nginx_advanced_service` has load-balancing primitives (round-robin/least-conn/IP-hash) but WP containers are stateful (local uploads, no shared object cache).
|
||||
|
||||
Reference in new issue
Block a user