Files
ServerKit/backend/app/models/workspace.py
T
Juan DenisandClaude Opus 4.8 d9a5ec7e92 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>
2026-06-01 11:59:34 -04:00

181 lines
6.5 KiB
Python

from datetime import datetime
from app import db
import json
class Workspace(db.Model):
"""Isolated container for servers, users, and settings."""
__tablename__ = 'workspaces'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), nullable=False, unique=True)
slug = db.Column(db.String(128), nullable=False, unique=True)
description = db.Column(db.Text)
# Branding
logo_url = db.Column(db.String(512))
primary_color = db.Column(db.String(7)) # hex color
# Settings
settings_json = db.Column(db.Text)
# Quotas
max_servers = db.Column(db.Integer, default=0) # 0 = unlimited
max_users = db.Column(db.Integer, default=0)
max_api_calls = db.Column(db.Integer, default=0)
# Status
STATUS_ACTIVE = 'active'
STATUS_ARCHIVED = 'archived'
status = db.Column(db.String(32), default=STATUS_ACTIVE)
# Billing
billing_notes = db.Column(db.Text)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
members = db.relationship('WorkspaceMember', backref='workspace', lazy='dynamic')
api_keys = db.relationship('WorkspaceApiKey', backref='workspace', lazy='dynamic')
@property
def settings(self):
return json.loads(self.settings_json) if self.settings_json else {}
@settings.setter
def settings(self, v):
self.settings_json = json.dumps(v)
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'slug': self.slug,
'description': self.description,
'logo_url': self.logo_url,
'primary_color': self.primary_color,
'settings': self.settings,
'max_servers': self.max_servers,
'max_users': self.max_users,
'max_api_calls': self.max_api_calls,
'status': self.status,
'member_count': self.members.count(),
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<Workspace {self.name}>'
class WorkspaceMember(db.Model):
"""Maps users to workspaces with roles."""
__tablename__ = 'workspace_members'
id = db.Column(db.Integer, primary_key=True)
workspace_id = db.Column(db.Integer, db.ForeignKey('workspaces.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
ROLE_OWNER = 'owner'
ROLE_ADMIN = 'admin'
ROLE_MEMBER = 'member'
ROLE_VIEWER = 'viewer'
role = db.Column(db.String(32), default=ROLE_MEMBER)
joined_at = db.Column(db.DateTime, default=datetime.utcnow)
user = db.relationship('User', backref=db.backref('workspace_memberships', lazy='dynamic'))
__table_args__ = (
db.UniqueConstraint('workspace_id', 'user_id', name='uq_workspace_user'),
)
def to_dict(self):
return {
'id': self.id,
'workspace_id': self.workspace_id,
'user_id': self.user_id,
'username': self.user.username if self.user else None,
'email': self.user.email if self.user else None,
'role': self.role,
'joined_at': self.joined_at.isoformat() if self.joined_at else None,
}
class WorkspaceApiKey(db.Model):
"""API keys scoped to a single workspace."""
__tablename__ = 'workspace_api_keys'
id = db.Column(db.Integer, primary_key=True)
workspace_id = db.Column(db.Integer, db.ForeignKey('workspaces.id'), nullable=False)
name = db.Column(db.String(128), nullable=False)
key_hash = db.Column(db.String(256), nullable=False)
key_prefix = db.Column(db.String(16))
scopes_json = db.Column(db.Text)
is_active = db.Column(db.Boolean, default=True)
expires_at = db.Column(db.DateTime, nullable=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_used_at = db.Column(db.DateTime)
@property
def scopes(self):
return json.loads(self.scopes_json) if self.scopes_json else []
@scopes.setter
def scopes(self, v):
self.scopes_json = json.dumps(v)
def to_dict(self):
return {
'id': self.id,
'workspace_id': self.workspace_id,
'name': self.name,
'key_prefix': self.key_prefix,
'scopes': self.scopes,
'is_active': self.is_active,
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
'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,
}