Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1# This file is part of daf_butler 

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 

28from typing import Dict, List, Optional, Tuple, Union 

29import yaml 

30 

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

32 

33 

34class DbAuthError(RuntimeError): 

35 """A problem has occurred retrieving database authentication information. 

36 """ 

37 pass 

38 

39 

40class DbAuthNotFoundError(DbAuthError): 

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

42 

43 

44class DbAuthPermissionsError(DbAuthError): 

45 """Credentials file has incorrect permissions.""" 

46 

47 

48class DbAuth: 

49 """Retrieves authentication information for database connections. 

50 

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

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

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

54 

55 Parameters 

56 ---------- 

57 path : `str` or None, optional 

58 Path to configuration file. 

59 envVar : `str` or None, optional 

60 Name of environment variable pointing to configuration file. 

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

62 Authentication configuration. 

63 

64 Notes 

65 ----- 

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

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

68 """ 

69 def __init__(self, path: Optional[str] = None, envVar: Optional[str] = None, 

70 authList: Optional[List[Dict[str, str]]] = None): 

71 if authList is not None: 

72 self.authList = authList 

73 return 

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

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

76 elif path is None: 

77 raise DbAuthNotFoundError( 

78 "No default path provided to DbAuth configuration file") 

79 else: 

80 secretPath = os.path.expanduser(path) 

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

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

83 mode = os.stat(secretPath).st_mode 

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

85 raise DbAuthPermissionsError( 

86 "DbAuth configuration file {} has incorrect permissions: " 

87 "{:o}".format(secretPath, mode)) 

88 

89 try: 

90 with open(secretPath) as secretFile: 

91 self.authList = yaml.safe_load(secretFile) 

92 except Exception as exc: 

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

94 

95 # Host is tagged as Optional only because other routines delegate to this 

96 # one in order to raise a consistent exception for that condition. 

97 def getAuth(self, dialectname: str, username: Optional[str], host: Optional[str], 

98 port: Optional[Union[int, str]], database: str) -> Tuple[Optional[str], str]: 

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

100 

101 This function matches elements from the database connection URL with 

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

103 

104 Parameters 

105 ---------- 

106 dialectname : `str` 

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

108 or mssql. 

109 username : `str` or None 

110 Username from connection URL if present. 

111 host : `str` 

112 Host name from connection URL if present. 

113 port : `str` or `int` or None 

114 Port from connection URL if present. 

115 database : `str` 

116 Database name from connection URL. 

117 

118 Returns 

119 ------- 

120 username: `str` 

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

122 present. 

123 password: `str` 

124 Password to use for database connection. 

125 

126 Raises 

127 ------ 

128 DbAuthError 

129 Raised if the input is missing elements, an authorization 

130 dictionary is missing elements, the authorization file is 

131 misconfigured, or no matching authorization is found. 

132 

133 Notes 

134 ----- 

135 The list of authorization configuration dictionaries is tested in 

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

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

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

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

140 ``username`` item. 

141 

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

143 specify dialect+driver. 

144 

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

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

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

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

149 

150 Examples 

151 -------- 

152 

153 The connection URL 

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

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

156 include: 

157 

158 * ``postgresql://*`` 

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

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

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

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

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

164 

165 Note that the connection URL 

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

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

168 port for the connection is 5432. 

169 """ 

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

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

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

173 raise DbAuthError("Missing dialectname parameter") 

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

175 raise DbAuthError("Missing host parameter") 

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

177 raise DbAuthError("Missing database parameter") 

178 

179 for authDict in self.authList: 

180 

181 # Check for mandatory entries 

182 if "url" not in authDict: 

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

184 

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

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

187 

188 # Check for same database backend type/dialect 

189 if components.scheme == "": 

190 raise DbAuthError( 

191 "Missing database dialect in URL: " + authDict["url"]) 

192 

193 if "+" in components.scheme: 

194 raise DbAuthError("Authorization dictionary URLs should only specify " 

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

196 

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

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

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

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

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

202 if dialect != components.scheme: 

203 continue 

204 

205 # Check for same database name 

206 if components.path != "" and components.path != "/": 

207 if not fnmatch.fnmatch(database, components.path.lstrip("/")): 

208 continue 

209 

210 # Check username 

211 if components.username is not None: 

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

213 continue 

214 if username != components.username: 

215 continue 

216 

217 # Check hostname 

218 if components.hostname is None: 

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

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

221 continue 

222 

223 # Check port 

224 if components.port is not None and \ 

225 (port is None or str(port) != str(components.port)): 

226 continue 

227 

228 # Don't override username from connection string 

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

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

231 else: 

232 if "username" not in authDict: 

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

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

235 

236 raise DbAuthNotFoundError( 

237 "No matching DbAuth configuration for: " 

238 f"({dialectname}, {username}, {host}, {port}, {database})") 

239 

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

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

242 

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

244 

245 Parameters 

246 ---------- 

247 url : `str` 

248 Database connection URL. 

249 

250 Returns 

251 ------- 

252 url : `str` 

253 Database connection URL with username and password. 

254 

255 Raises 

256 ------ 

257 DbAuthError 

258 Raised if the input is missing elements, an authorization 

259 dictionary is missing elements, the authorization file is 

260 misconfigured, or no matching authorization is found. 

261 

262 See also 

263 -------- 

264 getAuth 

265 """ 

266 components = urllib.parse.urlparse(url) 

267 username, password = self.getAuth( 

268 components.scheme, 

269 components.username, components.hostname, components.port, 

270 components.path.lstrip("/")) 

271 hostname = components.hostname 

272 assert hostname is not None 

273 if ":" in hostname: # ipv6 

274 hostname = f"[{hostname}]" 

275 assert username is not None 

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

277 urllib.parse.quote(username, safe=""), 

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

279 if components.port is not None: 

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

281 return urllib.parse.urlunparse(( 

282 components.scheme, netloc, components.path, components.params, 

283 components.query, components.fragment))