Skip to content

Commit 0e17a3f

Browse files
niiish32xnishenghao.nsh
andauthored
feat(oauth2): persist configuration to database with display-only masking (#178)
Co-authored-by: nishenghao.nsh <nishenghao.nsh@oceanbase.com>
1 parent 269cf28 commit 0e17a3f

8 files changed

Lines changed: 419 additions & 8 deletions

File tree

assets/schema/oauth2_config.sql

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- OAuth2 Configuration Table
2+
-- Stores OAuth2 settings in database for persistence across deployments
3+
-- Note: client_secret is stored in plain text and masked when displayed
4+
5+
CREATE TABLE IF NOT EXISTS `oauth2_config` (
6+
`id` INT NOT NULL AUTO_INCREMENT COMMENT 'Primary key',
7+
`config_key` VARCHAR(64) NOT NULL COMMENT 'Configuration key (default: global)',
8+
`enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'OAuth2 enabled flag',
9+
`providers_json` TEXT NULL COMMENT 'OAuth2 providers configuration (JSON array)',
10+
`admin_users_json` TEXT NULL COMMENT 'Admin users list (JSON array)',
11+
`gmt_create` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Create time',
12+
`gmt_modify` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Modify time',
13+
PRIMARY KEY (`id`),
14+
UNIQUE KEY `uk_config_key` (`config_key`)
15+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
16+
COMMENT='OAuth2 configuration storage (client_secret masked on display)';
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
-- Upgrade script: Create OAuth2 config table for persistence
2+
3+
CREATE TABLE IF NOT EXISTS `oauth2_config` (
4+
`id` INT NOT NULL AUTO_INCREMENT COMMENT 'Primary key',
5+
`config_key` VARCHAR(64) NOT NULL COMMENT 'Configuration key (default: global)',
6+
`enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'OAuth2 enabled flag',
7+
`providers_json` TEXT NULL COMMENT 'OAuth2 providers configuration (JSON array)',
8+
`admin_users_json` TEXT NULL COMMENT 'Admin users list (JSON array)',
9+
`gmt_create` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Create time',
10+
`gmt_modify` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Modify time',
11+
PRIMARY KEY (`id`),
12+
UNIQUE KEY `uk_config_key` (`config_key`)
13+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
14+
COMMENT='OAuth2 configuration storage (client_secret masked on display)';
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- Upgrade script: Add OAuth and role columns to user table
2+
-- Run this if user table exists but missing columns for OAuth user management
3+
4+
-- Add columns if not exists (MySQL 8.0+)
5+
ALTER TABLE `user`
6+
ADD COLUMN IF NOT EXISTS `oauth_provider` VARCHAR(64) NULL COMMENT 'OAuth2 provider',
7+
ADD COLUMN IF NOT EXISTS `oauth_id` VARCHAR(255) NULL COMMENT 'OAuth provider user ID',
8+
ADD COLUMN IF NOT EXISTS `email` VARCHAR(255) NULL COMMENT 'User email',
9+
ADD COLUMN IF NOT EXISTS `avatar` VARCHAR(512) NULL COMMENT 'Avatar URL',
10+
ADD COLUMN IF NOT EXISTS `role` VARCHAR(20) NULL DEFAULT 'normal' COMMENT 'User role: normal/admin',
11+
ADD COLUMN IF NOT EXISTS `is_active` INT NOT NULL DEFAULT 1 COMMENT '1=active, 0=disabled',
12+
ADD INDEX IF NOT EXISTS `idx_oauth` (`oauth_provider`, `oauth_id`);
13+
14+
-- For older MySQL versions, use separate statements:
15+
-- ALTER TABLE `user` ADD COLUMN `oauth_provider` VARCHAR(64) NULL COMMENT 'OAuth2 provider';
16+
-- ALTER TABLE `user` ADD COLUMN `oauth_id` VARCHAR(255) NULL COMMENT 'OAuth provider user ID';
17+
-- ALTER TABLE `user` ADD COLUMN `email` VARCHAR(255) NULL COMMENT 'User email';
18+
-- ALTER TABLE `user` ADD COLUMN `avatar` VARCHAR(512) NULL COMMENT 'Avatar URL';
19+
-- ALTER TABLE `user` ADD COLUMN `role` VARCHAR(20) NULL DEFAULT 'normal' COMMENT 'User role: normal/admin';
20+
-- ALTER TABLE `user` ADD COLUMN `is_active` INT NOT NULL DEFAULT 1 COMMENT '1=active, 0=disabled';
21+
-- ALTER TABLE `user` ADD INDEX `idx_oauth` (`oauth_provider`, `oauth_id`);

packages/derisk-app/src/derisk_app/app.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,41 @@ def mount_static_files(app: FastAPI, param: ApplicationConfig):
164164
)
165165

166166

167+
def _sync_oauth2_config_from_db():
168+
"""Sync OAuth2 config from database to runtime config on startup.
169+
170+
This ensures that after deployment/restart, the OAuth2 configuration
171+
stored in database (which survives redeployment) is loaded into
172+
the in-memory config used by the application.
173+
"""
174+
try:
175+
from derisk_app.config_storage.oauth2_db_storage import get_oauth2_db_storage
176+
from derisk.configs.model_config import Config
177+
178+
db_storage = get_oauth2_db_storage()
179+
# Load with actual secrets for runtime use
180+
db_oauth2 = db_storage.load_with_secrets()
181+
182+
if db_oauth2 is not None:
183+
# Update the runtime config with database values
184+
cfg = Config()
185+
if hasattr(cfg, "oauth2"):
186+
from derisk_core.config import OAuth2Config
187+
188+
# Convert dict to OAuth2Config
189+
oauth2_config = OAuth2Config(
190+
enabled=db_oauth2.get("enabled", False),
191+
providers=db_oauth2.get("providers", []),
192+
admin_users=db_oauth2.get("admin_users", []),
193+
)
194+
cfg.oauth2 = oauth2_config
195+
logger.info("OAuth2 config loaded from database (secrets loaded for runtime)")
196+
else:
197+
logger.info("No OAuth2 config in database, using file config")
198+
except Exception as e:
199+
logger.warning(f"Failed to sync OAuth2 from database: {e}")
200+
201+
167202
def initialize_app(param: ApplicationConfig, app: FastAPI, system_app: SystemApp):
168203
"""Initialize app
169204
If you use gunicorn as a process manager, initialize_app can be invoke in
@@ -191,6 +226,9 @@ def initialize_app(param: ApplicationConfig, app: FastAPI, system_app: SystemApp
191226
param.service.web.database, web_config.disable_alembic_upgrade
192227
)
193228

229+
# Load OAuth2 config from database (if exists) to override file config
230+
_sync_oauth2_config_from_db()
231+
194232
from derisk_app.component_configs import initialize_components
195233

196234
initialize_components(
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Derisk configuration storage modules."""
2+
3+
from .oauth2_db_storage import OAuth2ConfigDao, OAuth2ConfigEntity, get_oauth2_db_storage
4+
5+
__all__ = [
6+
"OAuth2ConfigDao",
7+
"OAuth2ConfigEntity",
8+
"get_oauth2_db_storage",
9+
]
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
"""OAuth2 configuration database storage with encryption.
2+
3+
This module provides database persistence for OAuth2 configuration,
4+
with automatic encryption/decryption of sensitive fields like client_secret.
5+
"""
6+
7+
import json
8+
import logging
9+
from typing import Any, Dict, List, Optional
10+
11+
from cryptography.fernet import Fernet
12+
from sqlalchemy import Column, DateTime, Integer, String, Text
13+
14+
from derisk._private.config import Config
15+
from derisk.storage.metadata import BaseDao, Model
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
class OAuth2ConfigEntity(Model):
21+
"""OAuth2 configuration entity for database storage (plain text)."""
22+
23+
__tablename__ = "oauth2_config"
24+
25+
id = Column(Integer, primary_key=True, autoincrement=True)
26+
config_key = Column(
27+
String(64),
28+
nullable=False,
29+
default="global",
30+
comment="Configuration key (default: global)",
31+
)
32+
enabled = Column(
33+
Integer,
34+
nullable=False,
35+
default=0,
36+
comment="OAuth2 enabled flag (1=true, 0=false)",
37+
)
38+
providers_json = Column(
39+
Text,
40+
nullable=True,
41+
comment="OAuth2 providers configuration (JSON array)",
42+
)
43+
admin_users_json = Column(
44+
Text,
45+
nullable=True,
46+
comment="Admin users list (JSON array)",
47+
)
48+
gmt_create = Column(DateTime, nullable=True)
49+
gmt_modify = Column(DateTime, nullable=True)
50+
51+
def to_dict(self) -> Dict[str, Any]:
52+
"""Convert to dictionary."""
53+
return {
54+
"id": self.id,
55+
"config_key": self.config_key,
56+
"enabled": bool(self.enabled),
57+
"providers_json": self.providers_json,
58+
"admin_users_json": self.admin_users_json,
59+
}
60+
61+
62+
class OAuth2ConfigDao(BaseDao[OAuth2ConfigEntity, Any, Any]):
63+
"""DAO for OAuth2 configuration."""
64+
65+
def get_by_key(self, config_key: str = "global") -> Optional[OAuth2ConfigEntity]:
66+
"""Get OAuth2 config by key."""
67+
with self.session() as session:
68+
return (
69+
session.query(OAuth2ConfigEntity)
70+
.filter(OAuth2ConfigEntity.config_key == config_key)
71+
.first()
72+
)
73+
74+
@staticmethod
75+
def _is_masked_secret(secret: str) -> bool:
76+
"""Check if a secret is masked (e.g., 'abcd****')."""
77+
return bool(secret and "****" in secret)
78+
79+
def _merge_secrets(
80+
self,
81+
new_providers: List[Dict[str, Any]],
82+
old_providers: List[Dict[str, Any]],
83+
) -> List[Dict[str, Any]]:
84+
"""Merge new providers with old, preserving secrets when new values are masked.
85+
86+
If new provider's client_secret is masked (e.g., 'abcd****'),
87+
use the corresponding old provider's secret (if provider id matches).
88+
"""
89+
if not old_providers:
90+
return new_providers
91+
92+
# Build lookup for old providers by id
93+
old_by_id = {p.get("id", ""): p for p in old_providers if p.get("id")}
94+
95+
merged = []
96+
for new_p in new_providers:
97+
pid = new_p.get("id", "")
98+
new_secret = new_p.get("client_secret", "")
99+
100+
# If secret is masked and we have old provider with same id
101+
if self._is_masked_secret(new_secret) and pid in old_by_id:
102+
old_p = old_by_id[pid]
103+
old_secret = old_p.get("client_secret", "")
104+
105+
# Make a copy and replace masked secret with original
106+
merged_p = dict(new_p)
107+
merged_p["client_secret"] = old_secret
108+
merged.append(merged_p)
109+
else:
110+
# Secret is not masked (new value) or no old provider
111+
merged.append(new_p)
112+
113+
return merged
114+
115+
def save_or_update(
116+
self,
117+
enabled: bool,
118+
providers: List[Dict[str, Any]],
119+
admin_users: List[str],
120+
config_key: str = "global",
121+
) -> OAuth2ConfigEntity:
122+
"""Save or update OAuth2 config (stored in plain text, mask on display)."""
123+
from datetime import datetime
124+
125+
with self.session() as session:
126+
entity = (
127+
session.query(OAuth2ConfigEntity)
128+
.filter(OAuth2ConfigEntity.config_key == config_key)
129+
.first()
130+
)
131+
132+
# If entity exists, merge secrets to avoid overwriting with masked values
133+
if entity and entity.providers_json:
134+
try:
135+
old_providers = json.loads(entity.providers_json)
136+
providers = self._merge_secrets(providers, old_providers)
137+
except json.JSONDecodeError:
138+
pass
139+
140+
# Store providers as plain JSON (client_secret included, unmasked)
141+
providers_json = json.dumps(providers, ensure_ascii=False)
142+
admin_users_json = json.dumps(admin_users, ensure_ascii=False)
143+
144+
if entity:
145+
entity.enabled = 1 if enabled else 0
146+
entity.providers_json = providers_json
147+
entity.admin_users_json = admin_users_json
148+
entity.gmt_modify = datetime.utcnow()
149+
else:
150+
entity = OAuth2ConfigEntity(
151+
config_key=config_key,
152+
enabled=1 if enabled else 0,
153+
providers_json=providers_json,
154+
admin_users_json=admin_users_json,
155+
gmt_create=datetime.utcnow(),
156+
gmt_modify=datetime.utcnow(),
157+
)
158+
session.add(entity)
159+
160+
session.commit()
161+
session.refresh(entity)
162+
return entity
163+
164+
def _mask_providers_for_display(
165+
self, providers: List[Dict[str, Any]]
166+
) -> List[Dict[str, Any]]:
167+
"""Mask sensitive fields (client_secret) for display purposes.
168+
169+
This returns a copy with client_secret hidden (showing only first 4 chars).
170+
The actual secret remains in the database.
171+
"""
172+
if not providers:
173+
return []
174+
175+
masked = json.loads(json.dumps(providers))
176+
for provider in masked:
177+
secret = provider.get("client_secret", "")
178+
if secret and len(secret) > 4:
179+
# Show first 4 chars, mask the rest
180+
provider["client_secret"] = secret[:4] + "****"
181+
elif secret:
182+
provider["client_secret"] = "****"
183+
return masked
184+
185+
def get_config(self, config_key: str = "global", mask_secrets: bool = True) -> Optional[Dict[str, Any]]:
186+
"""Get OAuth2 config from database.
187+
188+
Args:
189+
config_key: Configuration key (default: global)
190+
mask_secrets: If True, mask client_secret in providers for display
191+
"""
192+
entity = self.get_by_key(config_key)
193+
if not entity:
194+
return None
195+
196+
try:
197+
admin_users = (
198+
json.loads(entity.admin_users_json)
199+
if entity.admin_users_json
200+
else []
201+
)
202+
except json.JSONDecodeError:
203+
admin_users = []
204+
205+
try:
206+
providers = json.loads(entity.providers_json or "[]")
207+
except json.JSONDecodeError:
208+
providers = []
209+
210+
# Mask secrets if requested (for display purposes)
211+
if mask_secrets:
212+
providers = self._mask_providers_for_display(providers)
213+
214+
return {
215+
"enabled": bool(entity.enabled),
216+
"providers": providers,
217+
"admin_users": admin_users,
218+
}
219+
220+
def get_config_with_secrets(self, config_key: str = "global") -> Optional[Dict[str, Any]]:
221+
"""Get OAuth2 config with actual secrets (for internal use only)."""
222+
return self.get_config(config_key, mask_secrets=False)
223+
224+
225+
class OAuth2DbStorage:
226+
"""High-level storage interface for OAuth2 config."""
227+
228+
def __init__(self):
229+
self._dao: Optional[OAuth2ConfigDao] = None
230+
231+
@property
232+
def dao(self) -> OAuth2ConfigDao:
233+
if self._dao is None:
234+
self._dao = OAuth2ConfigDao()
235+
return self._dao
236+
237+
def load(self, mask_secrets: bool = True) -> Optional[Dict[str, Any]]:
238+
"""Load OAuth2 config from database."""
239+
return self.dao.get_config("global", mask_secrets=mask_secrets)
240+
241+
def load_with_secrets(self) -> Optional[Dict[str, Any]]:
242+
"""Load OAuth2 config with actual secrets (for internal use only)."""
243+
return self.dao.get_config_with_secrets("global")
244+
245+
def save(self, enabled: bool, providers: List[Dict], admin_users: List[str]) -> bool:
246+
"""Save OAuth2 config to database."""
247+
try:
248+
self.dao.save_or_update(enabled, providers, admin_users, "global")
249+
return True
250+
except Exception as e:
251+
logger.exception(f"Failed to save OAuth2 config: {e}")
252+
return False
253+
254+
255+
# Singleton instance
256+
_oauth2_storage: Optional[OAuth2DbStorage] = None
257+
258+
259+
def get_oauth2_db_storage() -> OAuth2DbStorage:
260+
"""Get OAuth2 database storage singleton."""
261+
global _oauth2_storage
262+
if _oauth2_storage is None:
263+
_oauth2_storage = OAuth2DbStorage()
264+
return _oauth2_storage

packages/derisk-app/src/derisk_app/initialization/db_model_initialization.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from derisk_serve.mcp.models.models import ServeEntity as MCPServeEntity
3131
from derisk_serve.channel.models.models import ChannelEntity
3232
from derisk_app.auth.user_service import UserEntity
33+
from derisk_app.config_storage.oauth2_db_storage import OAuth2ConfigEntity
3334
from derisk_app.feature_plugins.user_groups.models import (
3435
UserGroupEntity,
3536
UserGroupMemberEntity,
@@ -60,4 +61,5 @@
6061
UserEntity,
6162
UserGroupEntity,
6263
UserGroupMemberEntity,
64+
OAuth2ConfigEntity,
6365
]

0 commit comments

Comments
 (0)