Skip to content

Commit 2408417

Browse files
authored
Merge pull request #1463 from dmach/credentials-from-env
Credentials from env
2 parents b5c8fa7 + 82216c7 commit 2408417

3 files changed

Lines changed: 253 additions & 37 deletions

File tree

osc/conf.py

Lines changed: 91 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1824,29 +1824,33 @@ def get_config(override_conffile=None,
18241824
else:
18251825
conffile = identify_conf()
18261826

1827-
conffile = os.path.expanduser(conffile)
1828-
if not os.path.exists(conffile):
1829-
raise oscerr.NoConfigfile(conffile, account_not_configured_text % conffile)
1827+
if conffile in ["", "/dev/null"]:
1828+
cp = OscConfigParser.OscConfigParser()
1829+
cp.add_section("general")
1830+
else:
1831+
conffile = os.path.expanduser(conffile)
1832+
if not os.path.exists(conffile):
1833+
raise oscerr.NoConfigfile(conffile, account_not_configured_text % conffile)
18301834

1831-
# make sure oscrc is not world readable, it may contain a password
1832-
conffile_stat = os.stat(conffile)
1833-
if conffile_stat.st_mode != 0o600:
1834-
try:
1835-
os.chmod(conffile, 0o600)
1836-
except OSError as e:
1837-
if e.errno in (errno.EROFS, errno.EPERM):
1838-
print(f"Warning: Configuration file '{conffile}' may have insecure file permissions.")
1839-
else:
1840-
raise e
1835+
# make sure oscrc is not world readable, it may contain a password
1836+
conffile_stat = os.stat(conffile)
1837+
if conffile_stat.st_mode != 0o600:
1838+
try:
1839+
os.chmod(conffile, 0o600)
1840+
except OSError as e:
1841+
if e.errno in (errno.EROFS, errno.EPERM):
1842+
print(f"Warning: Configuration file '{conffile}' may have insecure file permissions.")
1843+
else:
1844+
raise e
18411845

1842-
cp = get_configParser(conffile)
1846+
cp = get_configParser(conffile)
18431847

1844-
if not cp.has_section('general'):
1845-
# FIXME: it might be sufficient to just assume defaults?
1846-
msg = config_incomplete_text % conffile
1847-
defaults = Options().dict()
1848-
msg += new_conf_template % defaults
1849-
raise oscerr.ConfigError(msg, conffile)
1848+
if not cp.has_section("general"):
1849+
# FIXME: it might be sufficient to just assume defaults?
1850+
msg = config_incomplete_text % conffile
1851+
defaults = Options().dict()
1852+
msg += new_conf_template % defaults
1853+
raise oscerr.ConfigError(msg, conffile)
18501854

18511855
global config
18521856

@@ -1868,29 +1872,44 @@ def get_config(override_conffile=None,
18681872
urls = [i for i in cp.sections() if i != "general"]
18691873
for url in urls:
18701874
apiurl = sanitize_apiurl(url)
1871-
username = cp[url].get("user", None)
1872-
if username is None:
1873-
raise oscerr.ConfigMissingCredentialsError(f"No user found in section {url}", conffile, url)
1874-
1875+
# the username will be overwritten later while reading actual config values
1876+
username = cp[url].get("user", "")
18751877
host_options = HostOptions(apiurl=apiurl, username=username, _parent=config)
1878+
18761879
known_ini_keys = set()
18771880
for name, field in host_options.__fields__.items():
1881+
# the following code relies on interating through fields in a given order: aliases, username, credentials_mgr_class, password
1882+
18781883
ini_key = field.extra.get("ini_key", name)
18791884
known_ini_keys.add(ini_key)
18801885

1881-
if name == "password":
1882-
# we need to handle the password first because it may be stored in a keyring instead of a config file
1883-
creds_mgr = _get_credentials_manager(url, cp)
1884-
value = creds_mgr.get_password(url, host_options.username, defer=True, apiurl=host_options.apiurl)
1885-
if value is None:
1886-
raise oscerr.ConfigMissingCredentialsError("No password found in section {url}", conffile, url)
1887-
value = Password(value)
1886+
# iterate through aliases and store the value of the the first env that matches OSC_HOST_{ALIAS}_{NAME}
1887+
env_value = None
1888+
for alias in host_options.aliases:
1889+
alias = alias.replace("-", "_")
1890+
env_key = f"OSC_HOST_{alias.upper()}_{name.upper()}"
1891+
env_value = os.environ.get(env_key, None)
1892+
if env_value is not None:
1893+
break
1894+
1895+
if env_value is not None:
1896+
value = env_value
18881897
elif ini_key in cp[url]:
18891898
value = cp[url][ini_key]
18901899
else:
1891-
continue
1900+
value = None
18921901

1893-
host_options.set_value_from_string(name, value)
1902+
if name == "credentials_mgr_class":
1903+
# HACK: inject credentials_mgr_class back in case we have specified it from env to have it available for reading password
1904+
if value:
1905+
cp[url][credentials.AbstractCredentialsManager.config_entry] = value
1906+
elif name == "password":
1907+
creds_mgr = _get_credentials_manager(url, cp)
1908+
if env_value is None:
1909+
value = creds_mgr.get_password(url, host_options.username, defer=True, apiurl=host_options.apiurl)
1910+
1911+
if value is not None:
1912+
host_options.set_value_from_string(name, value)
18941913

18951914
for key, value in cp[url].items():
18961915
if key.startswith("_"):
@@ -1941,6 +1960,45 @@ def get_config(override_conffile=None,
19411960

19421961
config.set_value_from_string(name, value)
19431962

1963+
# BEGIN: override credentials for the default apiurl
1964+
1965+
# OSC_APIURL is handled already because it's a regular field
1966+
env_username = os.environ.get("OSC_USERNAME", "")
1967+
env_credentials_mgr_class = os.environ.get("OSC_CREDENTIALS_MGR_CLASS", None)
1968+
env_password = os.environ.get("OSC_PASSWORD", None)
1969+
1970+
if config.apiurl not in config.api_host_options:
1971+
host_options = HostOptions(apiurl=config.apiurl, username=env_username, _parent=config)
1972+
config.api_host_options[config.apiurl] = host_options
1973+
# HACK: inject section so we can add credentials_mgr_class later
1974+
cp.add_section(config.apiurl)
1975+
1976+
host_options = config.api_host_options[config.apiurl]
1977+
if env_username:
1978+
host_options.set_value_from_string("username", env_username)
1979+
1980+
if env_credentials_mgr_class:
1981+
host_options.set_value_from_string("credentials_mgr_class", env_credentials_mgr_class)
1982+
# HACK: inject credentials_mgr_class in case we have specified it from env to have it available for reading password
1983+
cp[config.apiurl]["credentials_mgr_class"] = env_credentials_mgr_class
1984+
1985+
if env_password:
1986+
password = Password(env_password)
1987+
host_options.password = password
1988+
elif env_credentials_mgr_class:
1989+
creds_mgr = _get_credentials_manager(config.apiurl, cp)
1990+
password = creds_mgr.get_password(config.apiurl, host_options.username, defer=True, apiurl=host_options.apiurl)
1991+
host_options.password = password
1992+
1993+
# END: override credentials for the default apiurl
1994+
1995+
for apiurl, host_options in config.api_host_options.items():
1996+
if not host_options.username:
1997+
raise oscerr.ConfigMissingCredentialsError(f"No user configured for apiurl {apiurl}", conffile, apiurl)
1998+
1999+
if not host_options.password:
2000+
raise oscerr.ConfigMissingCredentialsError(f"No password configured for apiurl {apiurl}", conffile, apiurl)
2001+
19442002
for key, value in cp["general"].items():
19452003
if key.startswith("_"):
19462004
continue

osc/credentials.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ def _process_options(self, options):
7474

7575
class PlaintextConfigFileCredentialsManager(AbstractCredentialsManager):
7676
def get_password(self, url, user, defer=True, apiurl=None):
77-
return self._cp.get(url, 'pass', raw=True)
77+
password = self._cp.get(url, "pass", fallback=None, raw=True)
78+
if password is None:
79+
return None
80+
return conf.Password(password)
7881

7982
def set_password(self, url, user, password):
8083
self._cp.set(url, 'pass', password)
@@ -108,7 +111,8 @@ def get_password(self, url, user, defer=True, apiurl=None):
108111
passwd = self._cp.get(url, 'passx', raw=True)
109112
else:
110113
passwd = super().get_password(url, user, apiurl=apiurl)
111-
return self.decode_password(passwd)
114+
password = self.decode_password(passwd)
115+
return conf.Password(password)
112116

113117
def set_password(self, url, user, password):
114118
compressed_pw = bz2.compress(password.encode('ascii'))

tests/test_conf.py

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import osc.conf
77

8+
from .common import patch
9+
810

911
OSCRC = """
1012
[general]
@@ -85,7 +87,7 @@
8587
user = Admin
8688
pass = opensuse
8789
passx = unused
88-
aliases = osc
90+
aliases = obs
8991
http_headers =
9092
Authorization: Basic QWRtaW46b3BlbnN1c2U=
9193
X-Foo: Bar
@@ -423,7 +425,7 @@ def test_extra_fields(self):
423425
self.assertEqual(host_options._extra_fields, {"plugin-option": "plugin-host-option", "new-option": "value"})
424426

425427
def test_apiurl_aliases(self):
426-
expected = {"https://api.opensuse.org": "https://api.opensuse.org", "osc": "https://api.opensuse.org"}
428+
expected = {"https://api.opensuse.org": "https://api.opensuse.org", "obs": "https://api.opensuse.org"}
427429
self.assertEqual(self.config.apiurl_aliases, expected)
428430
self.assertEqual(self.config["apiurl_aliases"], expected)
429431

@@ -489,5 +491,157 @@ def test_api_host_options(self):
489491
self.assertNotEqual(id(conf1.api_host_options), id(conf2.api_host_options))
490492

491493

494+
class TestCredentialsFromEnv(unittest.TestCase):
495+
def setUp(self):
496+
osc.conf.config = None
497+
self.oscrc = ""
498+
499+
@patch.dict(os.environ, {"OSC_APIURL": "https://example.com"}, clear=True)
500+
def test_new_apiurl(self):
501+
# missing user
502+
self.assertRaises(
503+
osc.oscerr.ConfigMissingCredentialsError,
504+
osc.conf.get_config,
505+
override_conffile=self.oscrc,
506+
)
507+
508+
@patch.dict(
509+
os.environ,
510+
{"OSC_APIURL": "https://example.com", "OSC_USERNAME": "user"},
511+
clear=True,
512+
)
513+
def test_new_apiurl_username(self):
514+
# missing password
515+
self.assertRaises(
516+
osc.oscerr.ConfigMissingCredentialsError,
517+
osc.conf.get_config,
518+
override_conffile=self.oscrc,
519+
)
520+
521+
@patch.dict(
522+
os.environ,
523+
{
524+
"OSC_APIURL": "https://example.com",
525+
"OSC_USERNAME": "user",
526+
"OSC_PASSWORD": "secret",
527+
},
528+
clear=True,
529+
)
530+
def test_new_apiurl_username_password(self):
531+
# missing password
532+
osc.conf.get_config(override_conffile=self.oscrc)
533+
conf = osc.conf.config
534+
host_options = conf["api_host_options"][conf["apiurl"]]
535+
self.assertEqual(conf.apiurl, "https://example.com")
536+
self.assertEqual(host_options.apiurl, "https://example.com")
537+
self.assertEqual(host_options.username, "user")
538+
self.assertEqual(host_options.password, "secret")
539+
self.assertEqual(host_options.credentials_mgr_class, None)
540+
541+
@patch.dict(
542+
os.environ,
543+
{
544+
"OSC_APIURL": "https://example.com",
545+
"OSC_USERNAME": "user",
546+
"OSC_PASSWORD": "secret",
547+
},
548+
clear=True,
549+
)
550+
def test_new_apiurl_username_password(self):
551+
# missing password
552+
osc.conf.get_config(override_conffile=self.oscrc)
553+
conf = osc.conf.config
554+
host_options = conf["api_host_options"][conf["apiurl"]]
555+
self.assertEqual(conf.apiurl, "https://example.com")
556+
self.assertEqual(host_options.apiurl, "https://example.com")
557+
self.assertEqual(host_options.username, "user")
558+
self.assertEqual(host_options.password, "secret")
559+
self.assertEqual(host_options.credentials_mgr_class, None)
560+
561+
@patch.dict(
562+
os.environ,
563+
{
564+
"OSC_APIURL": "https://example.com",
565+
"OSC_USERNAME": "user",
566+
"OSC_PASSWORD": "secret",
567+
"OSC_CREDENTIALS_MGR_CLASS": "osc.credentials.PlaintextConfigFileCredentialsManager",
568+
},
569+
clear=True,
570+
)
571+
def test_new_apiurl_username_password_credmgr(self):
572+
# missing password
573+
osc.conf.get_config(override_conffile=self.oscrc)
574+
conf = osc.conf.config
575+
host_options = conf["api_host_options"][conf.apiurl]
576+
self.assertEqual(conf.apiurl, "https://example.com")
577+
self.assertEqual(host_options.apiurl, "https://example.com")
578+
self.assertEqual(host_options.username, "user")
579+
self.assertEqual(host_options.password, "secret")
580+
self.assertEqual(host_options.credentials_mgr_class, "osc.credentials.PlaintextConfigFileCredentialsManager")
581+
582+
583+
class TestHostOptionsFromEnv(unittest.TestCase):
584+
def setUp(self):
585+
self.tmpdir = tempfile.mkdtemp(prefix="osc_test_")
586+
self.oscrc = os.path.join(self.tmpdir, "oscrc")
587+
with open(self.oscrc, "w", encoding="utf-8") as f:
588+
f.write(OSCRC)
589+
osc.conf.get_config(override_conffile=self.oscrc)
590+
self.config = osc.conf.config
591+
592+
def tearDown(self):
593+
shutil.rmtree(self.tmpdir)
594+
595+
@patch.dict(
596+
os.environ,
597+
{
598+
"OSC_HOST_OBS_USERNAME": "user",
599+
"OSC_HOST_OBS_PASSWORD": "secret",
600+
"OSC_HOST_OBS_CREDENTIALS_MGR_CLASS": "osc.credentials.PlaintextConfigFileCredentialsManager",
601+
"OSC_HOST_OBS_REALNAME": "User",
602+
"OSC_HOST_OBS_EMAIL": "user@example.com",
603+
},
604+
clear=True,
605+
)
606+
def test_host_options(self):
607+
osc.conf.get_config(override_conffile=self.oscrc)
608+
conf = osc.conf.config
609+
host_options = conf["api_host_options"][conf["apiurl"]]
610+
self.assertEqual(conf.apiurl, "https://api.opensuse.org")
611+
self.assertEqual(host_options.apiurl, "https://api.opensuse.org")
612+
self.assertEqual(host_options.username, "user")
613+
self.assertEqual(host_options.password, "secret")
614+
self.assertEqual(host_options.credentials_mgr_class, "osc.credentials.PlaintextConfigFileCredentialsManager")
615+
self.assertEqual(host_options.realname, "User")
616+
self.assertEqual(host_options.email, "user@example.com")
617+
618+
@patch.dict(
619+
os.environ,
620+
{
621+
"OSC_HOST_OBS_USERNAME": "user",
622+
"OSC_HOST_OBS_PASSWORD": "secret",
623+
"OSC_HOST_OBS_CREDENTIALS_MGR_CLASS": "osc.credentials.PlaintextConfigFileCredentialsManager",
624+
"OSC_HOST_OBS_REALNAME": "User",
625+
"OSC_HOST_OBS_EMAIL": "user@example.com",
626+
"OSC_USERNAME": "USER",
627+
"OSC_PASSWORD": "SECRET",
628+
"OSC_CREDENTIALS_MGR_CLASS": "osc.credentials.TransientCredentialsManager",
629+
},
630+
clear=True,
631+
)
632+
def test_host_options_overrides(self):
633+
# thest if OSC_{USERNAME,PASSWORD,CREDENTIALS_MGR_CLASS} prevail over OSC_HOST_* options
634+
osc.conf.get_config(override_conffile=self.oscrc)
635+
conf = osc.conf.config
636+
host_options = conf["api_host_options"][conf["apiurl"]]
637+
self.assertEqual(conf.apiurl, "https://api.opensuse.org")
638+
self.assertEqual(host_options.apiurl, "https://api.opensuse.org")
639+
self.assertEqual(host_options.username, "USER")
640+
self.assertEqual(host_options.password, "SECRET")
641+
self.assertEqual(host_options.credentials_mgr_class, "osc.credentials.TransientCredentialsManager")
642+
self.assertEqual(host_options.realname, "User")
643+
self.assertEqual(host_options.email, "user@example.com")
644+
645+
492646
if __name__ == "__main__":
493647
unittest.main()

0 commit comments

Comments
 (0)