Skip to content

Commit d12a92f

Browse files
committed
Add SaaS multi-tenancy data isolation
- Register users as admin in SaaS mode with their own account - Add owner_id to databases and created_by_id to users for tracking - Filter admin endpoints to only show data belonging to the admin's account - Add is_account_owner property to /me endpoint - Add Billing button in header navigation for account owners - Update frontend to use is_account_owner for admin/billing access - Add database migration 20241223_01 for new columns Bump version to 3.2.6
1 parent 65db2fe commit d12a92f

9 files changed

Lines changed: 148 additions & 21 deletions

File tree

client/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "client",
33
"private": true,
4-
"version": "3.2.5",
4+
"version": "3.2.6",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

client/src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ function App() {
283283
<>
284284
<Layout
285285
onAdminClick={openAdmin}
286+
onBillingClick={() => navigate('/billing')}
286287
sidebar={
287288
<Stack gap="xs">
288289
<Sidebar
@@ -308,7 +309,7 @@ function App() {
308309
/>
309310
<Divider />
310311
<Text size="xs" c="dimmed" ta="center">
311-
BillManager v3.2.5 - Licensed under{' '}
312+
BillManager v3.2.6 - Licensed under{' '}
312313
<Anchor href="https://osaasy.dev/" target="_blank" size="xs">
313314
O'Saasy
314315
</Anchor>

client/src/components/Layout.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@ import {
99
Burger,
1010
} from '@mantine/core';
1111
import { useDisclosure } from '@mantine/hooks';
12-
import { IconSun, IconMoon, IconSettings, IconLogout } from '@tabler/icons-react';
12+
import { IconSun, IconMoon, IconSettings, IconLogout, IconCreditCard } from '@tabler/icons-react';
1313
import { useAuth } from '../context/AuthContext';
1414
import type { ReactNode } from 'react';
1515

1616
interface LayoutProps {
1717
children: ReactNode;
1818
sidebar: ReactNode;
1919
onAdminClick: () => void;
20+
onBillingClick?: () => void;
2021
}
2122

22-
export function Layout({ children, sidebar, onAdminClick }: LayoutProps) {
23+
export function Layout({ children, sidebar, onAdminClick, onBillingClick }: LayoutProps) {
2324
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
2425
const [opened, { toggle }] = useDisclosure();
2526
const { isLoggedIn, isAdmin, databases, currentDb, selectDatabase, logout } = useAuth();
@@ -70,6 +71,17 @@ export function Layout({ children, sidebar, onAdminClick }: LayoutProps) {
7071

7172
{isLoggedIn && (
7273
<Group gap="xs">
74+
{isAdmin && onBillingClick && (
75+
<Button
76+
variant="light"
77+
color="billGreen"
78+
size="sm"
79+
leftSection={<IconCreditCard size={16} />}
80+
onClick={onBillingClick}
81+
>
82+
Billing
83+
</Button>
84+
)}
7385
{isAdmin && (
7486
<Button
7587
variant="light"

client/src/context/AuthContext.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
5151
const response = await api.getMe();
5252
setState({
5353
isLoggedIn: true,
54-
isAdmin: response.data.role === 'admin',
54+
// Use is_account_owner from API (true for account owners who can access admin/billing)
55+
isAdmin: response.data.is_account_owner ?? response.data.role === 'admin',
5556
role: response.data.role,
5657
databases: response.data.databases,
5758
currentDb: response.data.current_db,
@@ -92,7 +93,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
9293

9394
setState({
9495
isLoggedIn: true,
95-
isAdmin: response.data.role === 'admin',
96+
// Use is_account_owner from API (true for account owners who can access admin/billing)
97+
isAdmin: (response.data as any).is_account_owner ?? response.data.role === 'admin',
9698
role: response.data.role,
9799
databases: response.data.databases || [],
98100
currentDb: response.data.databases?.[0]?.name || null,

server/app.py

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,13 @@ def logout():
413413
def me():
414414
user = User.query.get(session['user_id'])
415415
dbs = [{'id': d.id, 'name': d.name, 'display_name': d.display_name} for d in user.accessible_databases]
416-
return jsonify({'username': user.username, 'role': user.role, 'databases': dbs, 'current_db': session.get('db_name')})
416+
return jsonify({
417+
'username': user.username,
418+
'role': user.role,
419+
'databases': dbs,
420+
'current_db': session.get('db_name'),
421+
'is_account_owner': user.is_account_owner if is_saas() else (user.role == 'admin')
422+
})
417423

418424
@api_bp.route('/select-db/<string:db_name>', methods=['POST'])
419425
@login_required
@@ -426,32 +432,56 @@ def select_database(db_name):
426432
@api_bp.route('/databases', methods=['GET', 'POST'])
427433
@admin_required
428434
def databases_handler():
435+
current_user = User.query.get(session.get('user_id'))
429436
if request.method == 'GET':
430-
dbs = Database.query.order_by(Database.created_at.desc()).all()
437+
# In SaaS mode, only show databases owned by this admin
438+
if is_saas():
439+
dbs = Database.query.filter_by(owner_id=current_user.id).order_by(Database.created_at.desc()).all()
440+
else:
441+
dbs = Database.query.order_by(Database.created_at.desc()).all()
431442
return jsonify([{'id': d.id, 'name': d.name, 'display_name': d.display_name, 'description': d.description} for d in dbs])
432443
else:
433444
data = request.get_json(); name, display_name = data.get('name'), data.get('display_name')
434445
if not name or not display_name: return jsonify({'error': 'Missing fields'}), 400
435446
if Database.query.filter_by(name=name).first(): return jsonify({'error': 'Exists'}), 400
436447
new_db = Database(name=name, display_name=display_name, description=data.get('description', ''))
448+
# In SaaS mode, set owner to current admin
449+
if is_saas():
450+
new_db.owner_id = current_user.id
437451
db.session.add(new_db)
438-
for admin in User.query.filter_by(role='admin').all(): admin.accessible_databases.append(new_db)
452+
# In SaaS mode, only grant access to this admin; in self-hosted, grant to all admins
453+
if is_saas():
454+
current_user.accessible_databases.append(new_db)
455+
else:
456+
for admin in User.query.filter_by(role='admin').all(): admin.accessible_databases.append(new_db)
439457
db.session.commit(); return jsonify({'message': 'Created', 'id': new_db.id}), 201
440458

441459
@api_bp.route('/databases/<int:db_id>', methods=['DELETE'])
442460
@admin_required
443461
def delete_database(db_id):
444462
target_db = Database.query.get_or_404(db_id)
463+
# In SaaS mode, only allow deleting databases you own
464+
if is_saas():
465+
current_user_id = session.get('user_id')
466+
if target_db.owner_id != current_user_id:
467+
return jsonify({'error': 'Access denied'}), 403
445468
db.session.delete(target_db); db.session.commit(); return jsonify({'message': 'Deleted'})
446469

447470
@api_bp.route('/databases/<int:db_id>/access', methods=['GET', 'POST'])
448471
@admin_required
449472
def database_access_handler(db_id):
450473
target_db = Database.query.get_or_404(db_id)
474+
current_user_id = session.get('user_id')
475+
# In SaaS mode, only allow managing access to databases you own
476+
if is_saas() and target_db.owner_id != current_user_id:
477+
return jsonify({'error': 'Access denied'}), 403
451478
if request.method == 'GET':
452479
return jsonify([{'id': u.id, 'username': u.username, 'role': u.role} for u in target_db.users])
453480
else:
454481
user = User.query.get_or_404(request.get_json().get('user_id'))
482+
# In SaaS mode, only allow granting access to users you created
483+
if is_saas() and user.created_by_id != current_user_id and user.id != current_user_id:
484+
return jsonify({'error': 'Cannot grant access to users outside your account'}), 403
455485
if target_db not in user.accessible_databases:
456486
user.accessible_databases.append(target_db); db.session.commit()
457487
return jsonify({'message': 'Granted'})
@@ -460,35 +490,65 @@ def database_access_handler(db_id):
460490
@admin_required
461491
def revoke_database_access(db_id, user_id):
462492
target_db = Database.query.get_or_404(db_id); user = User.query.get_or_404(user_id)
493+
current_user_id = session.get('user_id')
494+
# In SaaS mode, only allow revoking access to databases you own
495+
if is_saas() and target_db.owner_id != current_user_id:
496+
return jsonify({'error': 'Access denied'}), 403
463497
if target_db in user.accessible_databases:
464498
user.accessible_databases.remove(target_db); db.session.commit()
465499
return jsonify({'message': 'Revoked'})
466500

467501
@api_bp.route('/users', methods=['GET', 'POST'])
468502
@admin_required
469503
def users_handler():
504+
current_user_id = session.get('user_id')
505+
current_user = User.query.get(current_user_id)
470506
if request.method == 'GET':
471-
users = User.query.all(); return jsonify([{'id': u.id, 'username': u.username, 'role': u.role} for u in users])
507+
# In SaaS mode, only show users created by this admin (plus themselves)
508+
if is_saas():
509+
users = User.query.filter(
510+
(User.created_by_id == current_user_id) | (User.id == current_user_id)
511+
).all()
512+
else:
513+
users = User.query.all()
514+
return jsonify([{'id': u.id, 'username': u.username, 'role': u.role} for u in users])
472515
else:
473516
data = request.get_json(); username, password = data.get('username'), data.get('password')
474517
if User.query.filter_by(username=username).first(): return jsonify({'error': 'Taken'}), 400
475518
new_user = User(username=username, role=data.get('role', 'user'), password_change_required=True)
519+
# In SaaS mode, track who created this user
520+
if is_saas():
521+
new_user.created_by_id = current_user_id
476522
new_user.set_password(data.get('password')); db.session.add(new_user)
477523
for db_id in data.get('database_ids', []):
478-
d = Database.query.get(db_id);
479-
if d: new_user.accessible_databases.append(d)
524+
d = Database.query.get(db_id)
525+
# In SaaS mode, only allow assigning access to databases you own
526+
if d:
527+
if is_saas() and d.owner_id != current_user_id:
528+
continue # Skip databases not owned by this admin
529+
new_user.accessible_databases.append(d)
480530
db.session.commit(); return jsonify({'message': 'Created', 'id': new_user.id}), 201
481531

482532
@api_bp.route('/users/<int:user_id>', methods=['DELETE'])
483533
@admin_required
484534
def delete_user(user_id):
485535
if user_id == session.get('user_id'): return jsonify({'error': 'Self'}), 400
486-
user = User.query.get_or_404(user_id); db.session.delete(user); db.session.commit(); return jsonify({'message': 'Deleted'})
536+
user = User.query.get_or_404(user_id)
537+
# In SaaS mode, only allow deleting users you created
538+
if is_saas():
539+
current_user_id = session.get('user_id')
540+
if user.created_by_id != current_user_id:
541+
return jsonify({'error': 'Access denied'}), 403
542+
db.session.delete(user); db.session.commit(); return jsonify({'message': 'Deleted'})
487543

488544
@api_bp.route('/users/<int:user_id>/databases', methods=['GET'])
489545
@admin_required
490546
def get_user_databases(user_id):
491547
user = User.query.get_or_404(user_id)
548+
current_user_id = session.get('user_id')
549+
# In SaaS mode, only allow viewing databases of users you created (or yourself)
550+
if is_saas() and user.created_by_id != current_user_id and user.id != current_user_id:
551+
return jsonify({'error': 'Access denied'}), 403
492552
return jsonify([{'id': d.id, 'name': d.name, 'display_name': d.display_name} for d in user.accessible_databases])
493553

494554
@api_bp.route('/api/accounts', methods=['GET'])
@@ -676,7 +736,7 @@ def process_auto_payments():
676736

677737
@api_bp.route('/api/version', methods=['GET'])
678738
def get_version():
679-
return jsonify({'version': '3.2.5', 'license': "O'Saasy", 'license_url': 'https://osaasy.dev/', 'features': ['enhanced_frequencies', 'auto_payments', 'postgresql_saas', 'row_tenancy']})
739+
return jsonify({'version': '3.2.6', 'license': "O'Saasy", 'license_url': 'https://osaasy.dev/', 'features': ['enhanced_frequencies', 'auto_payments', 'postgresql_saas', 'row_tenancy']})
680740

681741
@api_bp.route('/ping')
682742
def ping(): return jsonify({'status': 'ok'})
@@ -845,8 +905,9 @@ def register():
845905
if User.query.filter_by(email=email).first():
846906
return jsonify({'success': False, 'error': 'Email already registered'}), 409
847907

848-
# Create user
849-
user = User(username=username, email=email, role='user')
908+
# Create user - in SaaS mode, each registered user is an admin of their own account
909+
user_role = 'admin' if is_saas() else 'user'
910+
user = User(username=username, email=email, role=user_role)
850911
user.set_password(password)
851912

852913
# For self-hosted mode without email verification, mark as verified
@@ -872,6 +933,10 @@ def register():
872933
db.session.add(default_db)
873934
db.session.flush() # Get the IDs
874935

936+
# In SaaS mode, set the owner_id to track which admin owns this database
937+
if is_saas():
938+
default_db.owner_id = user.id
939+
875940
# Grant user access to their default database
876941
user.accessible_databases.append(default_db)
877942

@@ -1400,7 +1465,8 @@ def jwt_me():
14001465
'username': user.username,
14011466
'role': user.role,
14021467
'databases': databases,
1403-
'current_db': g.jwt_db_name
1468+
'current_db': g.jwt_db_name,
1469+
'is_account_owner': user.is_account_owner if is_saas() else (user.role == 'admin')
14041470
}
14051471
})
14061472

@@ -1809,7 +1875,7 @@ def jwt_get_version():
18091875
return jsonify({
18101876
'success': True,
18111877
'data': {
1812-
'version': '3.2.5',
1878+
'version': '3.2.6',
18131879
'api_version': 'v2',
18141880
'license': "O'Saasy",
18151881
'license_url': 'https://osaasy.dev/',

server/db_migrations.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,36 @@ def migrate_20241222_01_add_subscription_tier(db):
106106
db.session.commit()
107107

108108

109+
def migrate_20241223_01_add_saas_tenancy_columns(db):
110+
"""Add owner_id to databases and created_by_id to users for SaaS multi-tenancy."""
111+
inspector = inspect(db.engine)
112+
113+
# Add created_by_id to users table
114+
user_columns = [col['name'] for col in inspector.get_columns('users')]
115+
if 'created_by_id' not in user_columns:
116+
db.session.execute(text('''
117+
ALTER TABLE users ADD COLUMN created_by_id INTEGER REFERENCES users(id)
118+
'''))
119+
logger.info("Added users.created_by_id column")
120+
121+
# Add owner_id to databases table
122+
db_columns = [col['name'] for col in inspector.get_columns('databases')]
123+
if 'owner_id' not in db_columns:
124+
db.session.execute(text('''
125+
ALTER TABLE databases ADD COLUMN owner_id INTEGER REFERENCES users(id)
126+
'''))
127+
logger.info("Added databases.owner_id column")
128+
129+
db.session.commit()
130+
131+
109132
# List of all migrations in order
110133
# Format: (version, description, function)
111134
MIGRATIONS = [
112135
('20241221_01', 'Increase password_hash column to 256 chars', migrate_20241221_01_password_hash_length),
113136
('20241221_02', 'Add migrations tracking index', migrate_20241221_02_add_migrations_index),
114137
('20241222_01', 'Add subscription tier and billing_interval columns', migrate_20241222_01_add_subscription_tier),
138+
('20241223_01', 'Add SaaS multi-tenancy columns (owner_id, created_by_id)', migrate_20241223_01_add_saas_tenancy_columns),
115139
]
116140

117141

server/models.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ class User(db.Model):
2222
change_token = db.Column(db.String(64), nullable=True)
2323
created_at = db.Column(db.DateTime, default=datetime.utcnow)
2424

25+
# SaaS multi-tenancy: track which admin created this user (null for self-registered admins)
26+
created_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
27+
2528
# Email and verification (for SaaS registration)
2629
email = db.Column(db.String(255), unique=True, nullable=True)
2730
email_verified_at = db.Column(db.DateTime, nullable=True)
@@ -37,6 +40,21 @@ class User(db.Model):
3740

3841
# Relationships
3942
accessible_databases = db.relationship('Database', secondary=user_database_access, backref='users')
43+
created_by = db.relationship('User', remote_side='User.id', foreign_keys=[created_by_id], backref='created_users')
44+
45+
@property
46+
def is_account_owner(self):
47+
"""Check if this user is an account owner (self-registered admin, not a sub-user)"""
48+
return self.role == 'admin' and self.created_by_id is None
49+
50+
@property
51+
def account_owner(self):
52+
"""Get the account owner for this user (self if admin, or the admin who created them)"""
53+
if self.is_account_owner:
54+
return self
55+
if self.created_by_id:
56+
return User.query.get(self.created_by_id)
57+
return None
4058

4159
def set_password(self, password):
4260
"""Hash password using werkzeug's secure method (pbkdf2:sha256)."""
@@ -116,8 +134,12 @@ class Database(db.Model):
116134
description = db.Column(db.String(255))
117135
created_at = db.Column(db.DateTime, default=datetime.utcnow)
118136

137+
# Owner tracking for SaaS multi-tenancy (which admin owns this database)
138+
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
139+
119140
# Relationships
120141
bills = db.relationship('Bill', backref='database', lazy=True, cascade="all, delete-orphan")
142+
owner = db.relationship('User', foreign_keys=[owner_id], backref='owned_databases')
121143

122144
class Bill(db.Model):
123145
__tablename__ = 'bills'

server/openapi.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ info:
1414
1515
Access tokens expire after 15 minutes. Refresh tokens expire after 7 days.
1616
17-
version: 3.2.5
17+
version: 3.2.6
1818
license:
1919
name: O'Saasy License
2020
url: https://osaasy.dev/

0 commit comments

Comments
 (0)