Coverage for python/lsst/utils/db_auth.py: 12%

80 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-05-01 15:14 -0700

1# This file is part of utils. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <http://www.gnu.org/licenses/>. 

21 

22from __future__ import annotations 

23 

24import fnmatch 

25import os 

26import stat 

27import urllib.parse 

28 

29import yaml 

30 

31__all__ = ["DbAuth", "DbAuthError", "DbAuthPermissionsError"] 

32 

33 

34class DbAuthError(RuntimeError): 

35 """Exception raised when a problem has occurred retrieving database 

36 authentication information. 

37 """ 

38 

39 pass 

40 

41 

42class DbAuthNotFoundError(DbAuthError): 

43 """Credentials file does not exist or no match was found in it.""" 

44 

45 

46class DbAuthPermissionsError(DbAuthError): 

47 """Credentials file has incorrect permissions.""" 

48 

49 

50class DbAuth: 

51 """Retrieves authentication information for database connections. 

52 

53 The authorization configuration is taken from the ``authList`` parameter 

54 or a (group- and world-inaccessible) YAML file located at a path specified 

55 by the given environment variable or at a default path location. 

56 

57 Parameters 

58 ---------- 

59 path : `str` or None, optional 

60 Path to configuration file. 

61 envVar : `str` or None, optional 

62 Name of environment variable pointing to configuration file. 

63 authList : `list` [`dict`] or None, optional 

64 Authentication configuration. 

65 

66 Notes 

67 ----- 

68 At least one of ``path``, ``envVar``, or ``authList`` must be provided; 

69 generally ``path`` should be provided as a default location. 

70 """ 

71 

72 def __init__( 

73 self, 

74 path: str | None = None, 

75 envVar: str | None = None, 

76 authList: list[dict[str, str]] | None = None, 

77 ): 

78 if authList is not None: 

79 self.authList = authList 

80 return 

81 if envVar is not None and envVar in os.environ: 

82 secretPath = os.path.expanduser(os.environ[envVar]) 

83 elif path is None: 

84 raise DbAuthNotFoundError("No default path provided to DbAuth configuration file") 

85 else: 

86 secretPath = os.path.expanduser(path) 

87 if not os.path.isfile(secretPath): 

88 raise DbAuthNotFoundError(f"No DbAuth configuration file: {secretPath}") 

89 mode = os.stat(secretPath).st_mode 

90 if mode & (stat.S_IRWXG | stat.S_IRWXO) != 0: 

91 raise DbAuthPermissionsError( 

92 f"DbAuth configuration file {secretPath} has incorrect permissions: {mode:o}" 

93 ) 

94 

95 try: 

96 with open(secretPath) as secretFile: 

97 self.authList = yaml.safe_load(secretFile) 

98 except Exception as exc: 

99 raise DbAuthError(f"Unable to load DbAuth configuration file: {secretPath}.") from exc 

100 

101 # dialectname, host, and database are tagged as Optional only because other 

102 # routines delegate to this one in order to raise a consistent exception 

103 # for that condition. 

104 def getAuth( 

105 self, 

106 dialectname: str | None, 

107 username: str | None, 

108 host: str | None, 

109 port: int | str | None, 

110 database: str | None, 

111 ) -> tuple[str | None, str]: 

112 """Retrieve a username and password for a database connection. 

113 

114 This function matches elements from the database connection URL with 

115 glob-like URL patterns in a list of configuration dictionaries. 

116 

117 Parameters 

118 ---------- 

119 dialectname : `str` 

120 Database dialect, for example sqlite, mysql, postgresql, oracle, 

121 or mssql. 

122 username : `str` or None 

123 Username from connection URL if present. 

124 host : `str` 

125 Host name from connection URL if present. 

126 port : `str` or `int` or None 

127 Port from connection URL if present. 

128 database : `str` 

129 Database name from connection URL. 

130 

131 Returns 

132 ------- 

133 username: `str` 

134 Username to use for database connection; same as parameter if 

135 present. 

136 password: `str` 

137 Password to use for database connection. 

138 

139 Raises 

140 ------ 

141 DbAuthError 

142 Raised if the input is missing elements, an authorization 

143 dictionary is missing elements, the authorization file is 

144 misconfigured, or no matching authorization is found. 

145 

146 Notes 

147 ----- 

148 The list of authorization configuration dictionaries is tested in 

149 order, with the first matching dictionary used. Each dictionary must 

150 contain a ``url`` item with a pattern to match against the database 

151 connection URL and a ``password`` item. If no username is provided in 

152 the database connection URL, the dictionary must also contain a 

153 ``username`` item. 

154 

155 The ``url`` item must begin with a dialect and is not allowed to 

156 specify dialect+driver. 

157 

158 Glob-style patterns (using "``*``" and "``?``" as wildcards) can be 

159 used to match the host and database name portions of the connection 

160 URL. For the username, port, and database name portions, omitting them 

161 from the pattern matches against any value in the connection URL. 

162 

163 Examples 

164 -------- 

165 The connection URL 

166 ``postgresql://user@host.example.com:5432/my_database`` matches against 

167 the identical string as a pattern. Other patterns that would match 

168 include: 

169 

170 * ``postgresql://*`` 

171 * ``postgresql://*.example.com`` 

172 * ``postgresql://*.example.com/my_*`` 

173 * ``postgresql://host.example.com/my_database`` 

174 * ``postgresql://host.example.com:5432/my_database`` 

175 * ``postgresql://user@host.example.com/my_database`` 

176 

177 Note that the connection URL 

178 ``postgresql://host.example.com/my_database`` would not match against 

179 the pattern ``postgresql://host.example.com:5432``, even if the default 

180 port for the connection is 5432. 

181 """ 

182 # Check inputs, squashing MyPy warnings that they're unnecessary 

183 # (since they're only unnecessary if everyone else runs MyPy). 

184 if dialectname is None or dialectname == "": 

185 raise DbAuthError("Missing dialectname parameter") 

186 if host is None or host == "": 

187 raise DbAuthError("Missing host parameter") 

188 if database is None or database == "": 

189 raise DbAuthError("Missing database parameter") 

190 

191 for authDict in self.authList: 

192 # Check for mandatory entries 

193 if "url" not in authDict: 

194 raise DbAuthError("Missing URL in DbAuth configuration") 

195 

196 # Parse pseudo-URL from db-auth.yaml 

197 components = urllib.parse.urlparse(authDict["url"]) 

198 

199 # Check for same database backend type/dialect 

200 if components.scheme == "": 

201 raise DbAuthError("Missing database dialect in URL: " + authDict["url"]) 

202 

203 if "+" in components.scheme: 

204 raise DbAuthError( 

205 "Authorization dictionary URLs should only specify " 

206 f"dialects, got: {components.scheme}. instead." 

207 ) 

208 

209 # dialect and driver are allowed in db string, since functionality 

210 # could change. Connecting to a DB using different driver does not 

211 # change dbname/user/pass and other auth info so we ignore it. 

212 # https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls 

213 dialect = dialectname.split("+")[0] 

214 if dialect != components.scheme: 

215 continue 

216 

217 # Check for same database name 

218 if ( 

219 components.path != "" 

220 and components.path != "/" 

221 and not fnmatch.fnmatch(database, components.path.lstrip("/")) 

222 ): 

223 continue 

224 

225 # Check username 

226 if components.username is not None: 

227 if username is None or username == "": 

228 continue 

229 if username != components.username: 

230 continue 

231 

232 # Check hostname 

233 if components.hostname is None: 

234 raise DbAuthError("Missing host in URL: " + authDict["url"]) 

235 if not fnmatch.fnmatch(host, components.hostname): 

236 continue 

237 

238 # Check port 

239 if components.port is not None and (port is None or str(port) != str(components.port)): 

240 continue 

241 

242 # Don't override username from connection string 

243 if username is not None and username != "": 

244 return (username, authDict["password"]) 

245 else: 

246 if "username" not in authDict: 

247 return (None, authDict["password"]) 

248 return (authDict["username"], authDict["password"]) 

249 

250 raise DbAuthNotFoundError( 

251 f"No matching DbAuth configuration for: ({dialectname}, {username}, {host}, {port}, {database})" 

252 ) 

253 

254 def getUrl(self, url: str) -> str: 

255 """Fill in a username and password in a database connection URL. 

256 

257 This function parses the URL and calls `getAuth`. 

258 

259 Parameters 

260 ---------- 

261 url : `str` 

262 Database connection URL. 

263 

264 Returns 

265 ------- 

266 url : `str` 

267 Database connection URL with username and password. 

268 

269 Raises 

270 ------ 

271 DbAuthError 

272 Raised if the input is missing elements, an authorization 

273 dictionary is missing elements, the authorization file is 

274 misconfigured, or no matching authorization is found. 

275 

276 See Also 

277 -------- 

278 getAuth : Retrieve authentication credentials. 

279 """ 

280 components = urllib.parse.urlparse(url) 

281 username, password = self.getAuth( 

282 components.scheme, 

283 components.username, 

284 components.hostname, 

285 components.port, 

286 components.path.lstrip("/"), 

287 ) 

288 hostname = components.hostname 

289 assert hostname is not None 

290 if ":" in hostname: # ipv6 

291 hostname = f"[{hostname}]" 

292 assert username is not None 

293 netloc = "{}:{}@{}".format( 

294 urllib.parse.quote(username, safe=""), urllib.parse.quote(password, safe=""), hostname 

295 ) 

296 if components.port is not None: 

297 netloc += ":" + str(components.port) 

298 return urllib.parse.urlunparse( 

299 ( 

300 components.scheme, 

301 netloc, 

302 components.path, 

303 components.params, 

304 components.query, 

305 components.fragment, 

306 ) 

307 )