66from typing import Dict , Optional , cast
77from urllib .parse import quote , quote_plus , urlencode
88
9- from pydantic import Field , PostgresDsn , ValidationInfo , field_validator
9+ from pydantic import (
10+ Field ,
11+ PostgresDsn ,
12+ ValidationInfo ,
13+ field_validator ,
14+ model_validator ,
15+ )
1016from pydantic_settings import SettingsConfigDict
1117
1218from fides .config .utils import get_test_mode
@@ -101,12 +107,26 @@ class DatabaseSettings(FidesSettings):
101107 default = None ,
102108 description = "The hostname of the application read database server." ,
103109 )
104-
105- # TODO (LJ-663): add optional readonly_user
106- # TODO (LJ-663): add optional readonly_password
107- # TODO (LJ-663): add optional readonly_port
108- # TODO (LJ-663): add optional readonly_params
109- # TODO (LJ-663): add optional readonly_db
110+ readonly_user : Optional [str ] = Field (
111+ default = None ,
112+ description = "The database user for read-only database connections. If not provided and readonly_server is set, uses 'user'." ,
113+ )
114+ readonly_password : Optional [str ] = Field (
115+ default = None ,
116+ description = "The password for read-only database connections. If not provided and readonly_server is set, uses 'password'." ,
117+ )
118+ readonly_port : Optional [str ] = Field (
119+ default = None ,
120+ description = "The port for read-only database connections. If not provided and readonly_server is set, uses 'port'." ,
121+ )
122+ readonly_db : Optional [str ] = Field (
123+ default = None ,
124+ description = "The database name for read-only database connections. If not provided and readonly_server is set, uses 'db'." ,
125+ )
126+ readonly_params : Dict = Field (
127+ default = {},
128+ description = "Additional connection parameters for read-only database connections. If not provided and readonly_server is set, uses 'params'." ,
129+ )
110130
111131 task_engine_pool_size : int = Field (
112132 default = 50 ,
@@ -172,6 +192,11 @@ class DatabaseSettings(FidesSettings):
172192 description = "Programmatically created synchronous connection string for the configured database (either application or test)." ,
173193 exclude = True ,
174194 )
195+ async_readonly_database_uri : Optional [str ] = Field (
196+ default = None ,
197+ description = "Programmatically created asynchronous connection string for the read-only application database." ,
198+ exclude = True ,
199+ )
175200
176201 @field_validator ("password" , mode = "before" )
177202 @classmethod
@@ -181,6 +206,39 @@ def escape_password(cls, value: Optional[str]) -> Optional[str]:
181206 return quote_plus (value )
182207 return value
183208
209+ @model_validator (mode = "before" )
210+ @classmethod
211+ def resolve_readonly_fields (cls , values : Dict ) -> Dict :
212+ """
213+ If readonly_server is set but readonly fields are not provided,
214+ fall back to primary database values.
215+ """
216+ if values .get ("readonly_server" ):
217+ # Fall back to primary user if readonly_user not provided
218+ if values .get ("readonly_user" ) is None :
219+ values ["readonly_user" ] = values .get ("user" )
220+
221+ # Fall back to primary password if readonly_password not provided
222+ if values .get ("readonly_password" ) is None :
223+ values ["readonly_password" ] = values .get ("password" )
224+ # If readonly_password was provided directly, escape it
225+ elif isinstance (values .get ("readonly_password" ), str ):
226+ values ["readonly_password" ] = quote_plus (values ["readonly_password" ])
227+
228+ # Fall back to primary port if readonly_port not provided
229+ if values .get ("readonly_port" ) is None :
230+ values ["readonly_port" ] = values .get ("port" )
231+
232+ # Fall back to primary db if readonly_db not provided
233+ if values .get ("readonly_db" ) is None :
234+ values ["readonly_db" ] = values .get ("db" )
235+
236+ # Fall back to primary params if readonly_params not provided
237+ if not values .get ("readonly_params" ):
238+ values ["readonly_params" ] = values .get ("params" , {})
239+
240+ return values
241+
184242 @field_validator ("sync_database_uri" , mode = "before" )
185243 @classmethod
186244 def assemble_sync_database_uri (
@@ -281,25 +339,68 @@ def assemble_readonly_db_connection(
281339 if not info .data .get ("readonly_server" ):
282340 return None
283341 port : int = port_integer_converter (info )
342+ readonly_port : int = (
343+ port_integer_converter (info , "readonly_port" )
344+ if info .data .get ("readonly_port" )
345+ else port
346+ )
284347 return str (
285- # TODO: support optional readonly params for user, password, etc.
286348 PostgresDsn .build ( # pylint: disable=no-member
287- scheme = "postgresql" ,
288- username = info .data .get ("user" ),
289- password = info .data .get ("password" ),
349+ scheme = "postgresql+psycopg2" ,
350+ username = info .data .get ("readonly_user" ) or info .data .get ("user" ),
351+ password = info .data .get ("readonly_password" )
352+ or info .data .get ("password" ),
290353 host = info .data .get ("readonly_server" ),
291- port = port ,
292- path = f"{ info .data .get ('db' ) or '' } " ,
354+ port = readonly_port ,
355+ path = f"{ info .data .get ('readonly_db' ) or info . data . get ( ' db' ) or '' } " ,
293356 query = (
294357 urlencode (
295- cast (Dict , info .data .get ("params" )), quote_via = quote , safe = "/"
358+ cast (Dict , info .data .get ("readonly_params" )),
359+ quote_via = quote ,
360+ safe = "/" ,
296361 )
297- if info .data .get ("params " )
362+ if info .data .get ("readonly_params " )
298363 else None
299364 ),
300365 )
301366 )
302367
368+ @field_validator ("async_readonly_database_uri" , mode = "before" )
369+ @classmethod
370+ def assemble_async_readonly_db_connection (
371+ cls , v : Optional [str ], info : ValidationInfo
372+ ) -> Optional [str ]:
373+ """Join DB connection credentials into an async read-only connection string."""
374+ if isinstance (v , str ) and v :
375+ return v
376+ if not info .data .get ("readonly_server" ):
377+ return None
378+
379+ # Handle SSL params for asyncpg (same as async_database_uri)
380+ params = cast (Dict , deepcopy (info .data .get ("readonly_params" , {})))
381+ if "sslmode" in params :
382+ params ["ssl" ] = params .pop ("sslmode" )
383+ params .pop ("sslrootcert" , None )
384+
385+ port : int = port_integer_converter (info )
386+ readonly_port : int = (
387+ port_integer_converter (info , "readonly_port" )
388+ if info .data .get ("readonly_port" )
389+ else port
390+ )
391+ return str (
392+ PostgresDsn .build ( # pylint: disable=no-member
393+ scheme = "postgresql+asyncpg" ,
394+ username = info .data .get ("readonly_user" ) or info .data .get ("user" ),
395+ password = info .data .get ("readonly_password" )
396+ or info .data .get ("password" ),
397+ host = info .data .get ("readonly_server" ),
398+ port = readonly_port ,
399+ path = f"{ info .data .get ('readonly_db' ) or info .data .get ('db' ) or '' } " ,
400+ query = urlencode (params , quote_via = quote , safe = "/" ) if params else None ,
401+ )
402+ )
403+
303404 @field_validator ("sqlalchemy_test_database_uri" , mode = "before" )
304405 @classmethod
305406 def assemble_test_db_connection (cls , v : Optional [str ], info : ValidationInfo ) -> str :
0 commit comments