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 

22import fnmatch 

23import os 

24import stat 

25import urllib.parse 

26import yaml 

27 

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

29 

30 

31class DbAuthError(RuntimeError): 

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

33 """ 

34 pass 

35 

36 

37class DbAuthNotFoundError(DbAuthError): 

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

39 

40 

41class DbAuthPermissionsError(DbAuthError): 

42 """Credentials file has incorrect permissions.""" 

43 

44 

45class DbAuth: 

46 """Retrieves authentication information for database connections. 

47 

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

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

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

51 

52 Parameters 

53 ---------- 

54 path : `str` or None, optional 

55 Path to configuration file. 

56 envVar : `str` or None, optional 

57 Name of environment variable pointing to configuration file. 

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

59 Authentication configuration. 

60 

61 Notes 

62 ----- 

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

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

65 """ 

66 def __init__(self, path=None, envVar=None, authList=None): 

67 if authList is not None: 

68 self.authList = authList 

69 return 

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

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

72 elif path is None: 

73 raise DbAuthNotFoundError( 

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

75 else: 

76 secretPath = os.path.expanduser(path) 

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

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

79 mode = os.stat(secretPath).st_mode 

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

81 raise DbAuthPermissionsError( 

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

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

84 

85 try: 

86 with open(secretPath) as secretFile: 

87 self.authList = yaml.safe_load(secretFile) 

88 except Exception as exc: 

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

90 

91 def getAuth(self, dialectname, username, host, port, database): 

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

93 

94 This function matches elements from the database connection URL with 

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

96 

97 Parameters 

98 ---------- 

99 dialectname : `str` 

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

101 or mssql. 

102 username : `str` or None 

103 Username from connection URL if present. 

104 host : `str` 

105 Host name from connection URL if present. 

106 port : `str` or `int` or None 

107 Port from connection URL if present. 

108 database : `str` 

109 Database name from connection URL. 

110 

111 Returns 

112 ------- 

113 username: `str` 

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

115 present. 

116 password: `str` 

117 Password to use for database connection. 

118 

119 Raises 

120 ------ 

121 DbAuthError 

122 Raised if the input is missing elements, an authorization 

123 dictionary is missing elements, the authorization file is 

124 misconfigured, or no matching authorization is found. 

125 

126 Notes 

127 ----- 

128 The list of authorization configuration dictionaries is tested in 

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

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

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

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

133 ``username`` item. 

134 

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

136 specify dialect+driver. 

137 

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

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

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

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

142 

143 Examples 

144 -------- 

145 

146 The connection URL 

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

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

149 include: 

150 

151 * ``postgresql://*`` 

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

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

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

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

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

157 

158 Note that the connection URL 

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

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

161 port for the connection is 5432. 

162 """ 

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

164 raise DbAuthError("Missing dialectname parameter") 

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

166 raise DbAuthError("Missing host parameter") 

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

168 raise DbAuthError("Missing database parameter") 

169 

170 for authDict in self.authList: 

171 

172 # Check for mandatory entries 

173 if "url" not in authDict: 

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

175 

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

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

178 

179 # Check for same database backend type/dialect 

180 if components.scheme == "": 

181 raise DbAuthError( 

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

183 

184 if "+" in components.scheme: 

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

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

187 

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

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

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

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

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

193 if dialect != components.scheme: 

194 continue 

195 

196 # Check for same database name 

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

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

199 continue 

200 

201 # Check username 

202 if components.username is not None: 

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

204 continue 

205 if username != components.username: 

206 continue 

207 

208 # Check hostname 

209 if components.hostname is None: 

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

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

212 continue 

213 

214 # Check port 

215 if components.port is not None and \ 

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

217 continue 

218 

219 # Don't override username from connection string 

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

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

222 else: 

223 if "username" not in authDict: 

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

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

226 

227 raise DbAuthNotFoundError( 

228 "No matching DbAuth configuration for: " 

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

230 

231 def getUrl(self, url): 

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

233 

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

235 

236 Parameters 

237 ---------- 

238 url : `str` 

239 Database connection URL. 

240 

241 Returns 

242 ------- 

243 url : `str` 

244 Database connection URL with username and password. 

245 

246 Raises 

247 ------ 

248 DbAuthError 

249 Raised if the input is missing elements, an authorization 

250 dictionary is missing elements, the authorization file is 

251 misconfigured, or no matching authorization is found. 

252 

253 See also 

254 -------- 

255 getAuth 

256 """ 

257 components = urllib.parse.urlparse(url) 

258 username, password = self.getAuth( 

259 components.scheme, 

260 components.username, components.hostname, components.port, 

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

262 hostname = components.hostname 

263 if ":" in hostname: # ipv6 

264 hostname = f"[{hostname}]" 

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

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

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

268 if components.port is not None: 

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

270 return urllib.parse.urlunparse(( 

271 components.scheme, netloc, components.path, components.params, 

272 components.query, components.fragment))