briar-cli 1.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- briar/__init__.py +8 -0
- briar/__main__.py +11 -0
- briar/_registry.py +36 -0
- briar/agent/__init__.py +16 -0
- briar/agent/_llm.py +90 -0
- briar/agent/_llms/__init__.py +40 -0
- briar/agent/_llms/anthropic_llm.py +185 -0
- briar/agent/_llms/bedrock.py +154 -0
- briar/agent/_llms/gemini.py +152 -0
- briar/agent/_llms/openai_llm.py +129 -0
- briar/agent/runner.py +364 -0
- briar/agent/tools.py +355 -0
- briar/auth/__init__.py +42 -0
- briar/auth/_acquirer.py +121 -0
- briar/auth/_acquirers/__init__.py +62 -0
- briar/auth/_acquirers/aws_sso.py +185 -0
- briar/auth/_acquirers/aws_static.py +54 -0
- briar/auth/_acquirers/bitbucket.py +60 -0
- briar/auth/_acquirers/github_device.py +129 -0
- briar/auth/_acquirers/github_pat.py +44 -0
- briar/auth/_acquirers/infisical.py +80 -0
- briar/auth/_acquirers/jira_session.py +102 -0
- briar/auth/_acquirers/jira_token.py +61 -0
- briar/auth/_acquirers/linear.py +46 -0
- briar/auth/_prompt.py +155 -0
- briar/cli.py +149 -0
- briar/commands/__init__.py +48 -0
- briar/commands/agent.py +692 -0
- briar/commands/auth.py +281 -0
- briar/commands/base.py +46 -0
- briar/commands/context.py +143 -0
- briar/commands/dashboard.py +61 -0
- briar/commands/extract.py +79 -0
- briar/commands/iac.py +44 -0
- briar/commands/runbook.py +110 -0
- briar/commands/secrets.py +165 -0
- briar/commands/version.py +17 -0
- briar/credentials/__init__.py +48 -0
- briar/credentials/_bootstrap.py +93 -0
- briar/credentials/_bootstraps/__init__.py +80 -0
- briar/credentials/_bootstraps/infisical.py +115 -0
- briar/credentials/_store.py +63 -0
- briar/credentials/aws_secrets.py +102 -0
- briar/credentials/envfile.py +171 -0
- briar/credentials/infisical.py +183 -0
- briar/credentials/ssm.py +83 -0
- briar/credentials/vault.py +109 -0
- briar/dashboard/__init__.py +13 -0
- briar/dashboard/collectors.py +1354 -0
- briar/dashboard/server.py +154 -0
- briar/dashboard/templates/index.html +678 -0
- briar/decorators.py +48 -0
- briar/env_vars.py +92 -0
- briar/error_policy.py +273 -0
- briar/errors.py +48 -0
- briar/extract/__init__.py +52 -0
- briar/extract/_cloud.py +157 -0
- briar/extract/_clouds/__init__.py +39 -0
- briar/extract/_clouds/aws.py +190 -0
- briar/extract/_clouds/azure.py +152 -0
- briar/extract/_clouds/gcp.py +135 -0
- briar/extract/_gh.py +159 -0
- briar/extract/_provider.py +218 -0
- briar/extract/_providers/__init__.py +60 -0
- briar/extract/_providers/bitbucket.py +276 -0
- briar/extract/_providers/github.py +277 -0
- briar/extract/_tracker.py +124 -0
- briar/extract/_trackers/__init__.py +44 -0
- briar/extract/_trackers/_jira_auth.py +258 -0
- briar/extract/_trackers/bitbucket.py +131 -0
- briar/extract/_trackers/github_issues.py +139 -0
- briar/extract/_trackers/jira.py +191 -0
- briar/extract/_trackers/linear.py +172 -0
- briar/extract/_user_filter.py +150 -0
- briar/extract/active_tickets.py +71 -0
- briar/extract/active_work.py +87 -0
- briar/extract/aws_infra.py +79 -0
- briar/extract/aws_services/__init__.py +31 -0
- briar/extract/aws_services/base.py +25 -0
- briar/extract/aws_services/ecs.py +43 -0
- briar/extract/aws_services/lambda_.py +38 -0
- briar/extract/aws_services/logs.py +39 -0
- briar/extract/aws_services/rds.py +35 -0
- briar/extract/aws_services/sqs.py +25 -0
- briar/extract/base.py +290 -0
- briar/extract/code_hotspots.py +134 -0
- briar/extract/codebase_conventions.py +72 -0
- briar/extract/composer.py +85 -0
- briar/extract/github_deployments.py +106 -0
- briar/extract/language_detectors/__init__.py +24 -0
- briar/extract/language_detectors/base.py +29 -0
- briar/extract/language_detectors/go.py +26 -0
- briar/extract/language_detectors/node.py +42 -0
- briar/extract/language_detectors/python.py +41 -0
- briar/extract/pr_archaeology.py +131 -0
- briar/extract/pr_review_context.py +123 -0
- briar/extract/reviewer_profile.py +141 -0
- briar/extract/ticket_archaeology.py +115 -0
- briar/extract/ticket_context.py +95 -0
- briar/formatting/__init__.py +67 -0
- briar/formatting/base.py +25 -0
- briar/formatting/csv.py +34 -0
- briar/formatting/json.py +19 -0
- briar/formatting/quiet.py +29 -0
- briar/formatting/table.py +97 -0
- briar/formatting/yaml.py +35 -0
- briar/iac/__init__.py +18 -0
- briar/iac/config_file.py +114 -0
- briar/iac/models.py +232 -0
- briar/iac/reference_map.py +33 -0
- briar/iac/runbook/__init__.py +32 -0
- briar/iac/runbook/executor.py +365 -0
- briar/iac/runbook/models.py +156 -0
- briar/iac/runbook/scheduler.py +187 -0
- briar/iac/scaffold/__init__.py +25 -0
- briar/iac/scaffold/_composer.py +308 -0
- briar/iac/scaffold/_knowledge.py +119 -0
- briar/iac/scaffold/archetypes/__init__.py +26 -0
- briar/iac/scaffold/archetypes/base.py +85 -0
- briar/iac/scaffold/archetypes/engineer.py +64 -0
- briar/iac/scaffold/archetypes/pr_ci_fixer.py +100 -0
- briar/iac/scaffold/archetypes/pr_conflict_resolver.py +83 -0
- briar/iac/scaffold/archetypes/pr_fixer.py +62 -0
- briar/iac/scaffold/archetypes/triager.py +50 -0
- briar/iac/scaffold/base.py +19 -0
- briar/iac/scaffold/implementation.py +59 -0
- briar/iac/scaffold/pr_fixes.py +52 -0
- briar/iac/scaffold/rules/__init__.py +64 -0
- briar/iac/scaffold/rules/base.py +121 -0
- briar/iac/scaffold/rules/commit_as_human.md +22 -0
- briar/iac/scaffold/rules/minimum_correct_fix.md +20 -0
- briar/iac/scaffold/rules/no_force_push.md +19 -0
- briar/iac/scaffold/rules/no_new_pr_creation.md +16 -0
- briar/iac/scaffold/rules/no_workflow_file_edits.md +21 -0
- briar/iac/scaffold/rules/read_all_comments_first.md +20 -0
- briar/iac/scaffold/rules/skip_approved_green_prs.md +17 -0
- briar/iac/scaffold/shapes/__init__.py +38 -0
- briar/iac/scaffold/shapes/base.py +17 -0
- briar/iac/scaffold/shapes/one_shot.py +48 -0
- briar/iac/scaffold/shapes/plan_approve_act.py +134 -0
- briar/iac/scaffold/shapes/triage.py +49 -0
- briar/iac/scaffold/sources/__init__.py +26 -0
- briar/iac/scaffold/sources/aws.py +69 -0
- briar/iac/scaffold/sources/base.py +61 -0
- briar/iac/scaffold/sources/bitbucket.py +164 -0
- briar/iac/scaffold/sources/github.py +155 -0
- briar/iac/scaffold/sources/jira.py +147 -0
- briar/iac/scaffold/triggers/__init__.py +23 -0
- briar/iac/scaffold/triggers/base.py +24 -0
- briar/iac/scaffold/triggers/bitbucket_webhook.py +54 -0
- briar/iac/scaffold/triggers/github_webhook.py +52 -0
- briar/iac/scaffold/triggers/manual.py +16 -0
- briar/iac/scaffold/triggers/schedule_cron.py +37 -0
- briar/log_context.py +73 -0
- briar/logging.py +68 -0
- briar/messaging/__init__.py +66 -0
- briar/messaging/_writer.py +90 -0
- briar/messaging/bitbucket_pr_comment.py +93 -0
- briar/messaging/github_pr_comment.py +90 -0
- briar/messaging/jira_comment.py +63 -0
- briar/messaging/jira_transition.py +71 -0
- briar/messaging/slack_channel.py +73 -0
- briar/messaging/telegram_chat.py +68 -0
- briar/notify/__init__.py +44 -0
- briar/notify/_sink.py +27 -0
- briar/notify/email.py +59 -0
- briar/notify/pagerduty.py +67 -0
- briar/notify/slack.py +49 -0
- briar/notify/telegram.py +48 -0
- briar/pagination.py +37 -0
- briar/settings.py +3 -0
- briar/storage/__init__.py +73 -0
- briar/storage/base.py +137 -0
- briar/storage/file.py +111 -0
- briar/storage/postgres.py +353 -0
- briar_cli-1.1.1.dist-info/METADATA +1031 -0
- briar_cli-1.1.1.dist-info/RECORD +179 -0
- briar_cli-1.1.1.dist-info/WHEEL +4 -0
- briar_cli-1.1.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""AWS SSO / IAM Identity Center OIDC device-code acquirer.
|
|
2
|
+
|
|
3
|
+
Implements the same flow as ``aws sso login`` but vends STS
|
|
4
|
+
credentials directly into briar's env-var convention.
|
|
5
|
+
|
|
6
|
+
Three-step OIDC dance per the SSO spec:
|
|
7
|
+
1. RegisterClient → client_id, client_secret (cache friendly)
|
|
8
|
+
2. StartDeviceAuthorization → device_code, user_code, verification_uri
|
|
9
|
+
3. CreateToken (polled) → access_token
|
|
10
|
+
|
|
11
|
+
Then exchange the SSO access_token for STS credentials via
|
|
12
|
+
``sso.get_role_credentials(account_id, role_name, access_token)``.
|
|
13
|
+
|
|
14
|
+
Stores AWS_<COMPANY>_ACCESS_KEY_ID, _SECRET_ACCESS_KEY,
|
|
15
|
+
_SESSION_TOKEN, _REGION + records expires_at so the CLI can warn
|
|
16
|
+
the operator about the upcoming rotation."""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from typing import Any, Dict, List, Optional
|
|
23
|
+
|
|
24
|
+
from briar.auth._acquirer import CredentialAcquirer, Credentials, CredentialExpired
|
|
25
|
+
from briar.auth._prompt import PromptIO
|
|
26
|
+
from briar.env_vars import CredEnv
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AwsSsoAcquirer(CredentialAcquirer):
|
|
33
|
+
kind = "aws-sso"
|
|
34
|
+
display_name = "AWS IAM Identity Center (SSO device-code flow)"
|
|
35
|
+
|
|
36
|
+
def acquire(self, *, company: str, prompt: PromptIO) -> Credentials:
|
|
37
|
+
if not company:
|
|
38
|
+
raise ValueError("aws-sso: --company is required")
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
import boto3
|
|
42
|
+
import botocore.exceptions
|
|
43
|
+
except ImportError:
|
|
44
|
+
raise RuntimeError("aws-sso: boto3 not installed — run `pip install briar-cli` (base)")
|
|
45
|
+
|
|
46
|
+
prompt.info("==> AWS IAM Identity Center — device flow")
|
|
47
|
+
start_url = prompt.prompt(" SSO start URL (e.g. https://acme.awsapps.com/start): ").strip()
|
|
48
|
+
sso_region = prompt.prompt(" SSO region [us-east-1]: ").strip() or "us-east-1"
|
|
49
|
+
if not start_url:
|
|
50
|
+
raise ValueError("aws-sso: SSO start URL required")
|
|
51
|
+
|
|
52
|
+
oidc = boto3.client("sso-oidc", region_name=sso_region)
|
|
53
|
+
sso = boto3.client("sso", region_name=sso_region)
|
|
54
|
+
|
|
55
|
+
# Step 1: register a client (one-shot per session).
|
|
56
|
+
reg = oidc.register_client(clientName="briar-cli", clientType="public")
|
|
57
|
+
client_id = reg["clientId"]
|
|
58
|
+
client_secret = reg["clientSecret"]
|
|
59
|
+
|
|
60
|
+
# Step 2: kick off device auth.
|
|
61
|
+
device = oidc.start_device_authorization(
|
|
62
|
+
clientId=client_id,
|
|
63
|
+
clientSecret=client_secret,
|
|
64
|
+
startUrl=start_url,
|
|
65
|
+
)
|
|
66
|
+
verification_uri = device.get("verificationUriComplete") or device["verificationUri"]
|
|
67
|
+
user_code = device["userCode"]
|
|
68
|
+
device_code = device["deviceCode"]
|
|
69
|
+
interval = int(device.get("interval", 5))
|
|
70
|
+
expires_in = int(device.get("expiresIn", 600))
|
|
71
|
+
|
|
72
|
+
prompt.info(f" 1. Open {verification_uri}")
|
|
73
|
+
prompt.info(f" 2. Verify the code: {user_code}")
|
|
74
|
+
prompt.info(f" 3. Approve the briar-cli device")
|
|
75
|
+
prompt.info(f" polling every {interval}s for up to {expires_in}s …")
|
|
76
|
+
prompt.open_url(verification_uri)
|
|
77
|
+
|
|
78
|
+
# Step 3: poll for the SSO access token.
|
|
79
|
+
def _poll_once() -> Optional[Dict[str, Any]]:
|
|
80
|
+
try:
|
|
81
|
+
return oidc.create_token(
|
|
82
|
+
clientId=client_id,
|
|
83
|
+
clientSecret=client_secret,
|
|
84
|
+
grantType="urn:ietf:params:oauth:grant-type:device_code",
|
|
85
|
+
deviceCode=device_code,
|
|
86
|
+
)
|
|
87
|
+
except botocore.exceptions.ClientError as exc:
|
|
88
|
+
code = exc.response.get("Error", {}).get("Code", "")
|
|
89
|
+
if code in ("AuthorizationPendingException", "SlowDownException"):
|
|
90
|
+
return None
|
|
91
|
+
raise
|
|
92
|
+
except Exception as exc: # noqa: BLE001
|
|
93
|
+
log.debug("aws-sso poll error: %s", exc)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
token_resp = prompt.poll(every=interval, max_wait=expires_in, fn=_poll_once)
|
|
98
|
+
except TimeoutError:
|
|
99
|
+
raise RuntimeError("aws-sso: timed out waiting for authorisation")
|
|
100
|
+
|
|
101
|
+
access_token = token_resp["accessToken"] # type: ignore[index]
|
|
102
|
+
|
|
103
|
+
# Step 4: enumerate accounts/roles the operator has access to.
|
|
104
|
+
accounts = []
|
|
105
|
+
for page in sso.get_paginator("list_accounts").paginate(accessToken=access_token):
|
|
106
|
+
accounts.extend(page.get("accountList", []))
|
|
107
|
+
if not accounts:
|
|
108
|
+
raise RuntimeError("aws-sso: no accounts visible to this SSO identity")
|
|
109
|
+
|
|
110
|
+
if len(accounts) == 1:
|
|
111
|
+
account = accounts[0]
|
|
112
|
+
else:
|
|
113
|
+
prompt.info(" Multiple accounts available:")
|
|
114
|
+
for i, a in enumerate(accounts, start=1):
|
|
115
|
+
prompt.info(f" [{i}] {a['accountId']} {a.get('accountName', '')}")
|
|
116
|
+
idx = int(prompt.prompt(" Select account number: ").strip())
|
|
117
|
+
account = accounts[idx - 1]
|
|
118
|
+
account_id = account["accountId"]
|
|
119
|
+
|
|
120
|
+
roles = []
|
|
121
|
+
for page in sso.get_paginator("list_account_roles").paginate(accessToken=access_token, accountId=account_id):
|
|
122
|
+
roles.extend(page.get("roleList", []))
|
|
123
|
+
if not roles:
|
|
124
|
+
raise RuntimeError(f"aws-sso: no roles for account {account_id}")
|
|
125
|
+
if len(roles) == 1:
|
|
126
|
+
role = roles[0]
|
|
127
|
+
else:
|
|
128
|
+
prompt.info(f" Roles available for account {account_id}:")
|
|
129
|
+
for i, r in enumerate(roles, start=1):
|
|
130
|
+
prompt.info(f" [{i}] {r['roleName']}")
|
|
131
|
+
idx = int(prompt.prompt(" Select role number: ").strip())
|
|
132
|
+
role = roles[idx - 1]
|
|
133
|
+
role_name = role["roleName"]
|
|
134
|
+
|
|
135
|
+
# Step 5: exchange for STS credentials.
|
|
136
|
+
creds_resp = sso.get_role_credentials(
|
|
137
|
+
accessToken=access_token,
|
|
138
|
+
accountId=account_id,
|
|
139
|
+
roleName=role_name,
|
|
140
|
+
)
|
|
141
|
+
rc = creds_resp["roleCredentials"]
|
|
142
|
+
|
|
143
|
+
# Expiry is milliseconds since epoch in the SSO response.
|
|
144
|
+
expires_at_ms = rc.get("expiration")
|
|
145
|
+
expires_at = datetime.fromtimestamp(int(expires_at_ms) / 1000, tz=timezone.utc) if expires_at_ms else None
|
|
146
|
+
|
|
147
|
+
region = prompt.prompt(f" Default region for {company} extractions [{sso_region}]: ").strip() or sso_region
|
|
148
|
+
|
|
149
|
+
return Credentials(
|
|
150
|
+
provider_kind=self.kind,
|
|
151
|
+
entries={
|
|
152
|
+
CredEnv.AWS_KEY_ID.for_company(company): rc["accessKeyId"],
|
|
153
|
+
CredEnv.AWS_SECRET.for_company(company): rc["secretAccessKey"],
|
|
154
|
+
CredEnv.AWS_SESSION.for_company(company): rc.get("sessionToken", ""),
|
|
155
|
+
CredEnv.AWS_REGION.for_company(company): region,
|
|
156
|
+
},
|
|
157
|
+
expires_at=expires_at,
|
|
158
|
+
metadata={
|
|
159
|
+
"auth_mode": "sso-device",
|
|
160
|
+
"account_id": account_id,
|
|
161
|
+
"role_name": role_name,
|
|
162
|
+
"start_url": start_url,
|
|
163
|
+
"sso_region": sso_region,
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def refresh(self, *, company: str, existing: Credentials) -> Credentials:
|
|
168
|
+
"""SSO refresh requires the operator's still-valid SSO access
|
|
169
|
+
token (cached in ``~/.aws/sso/cache/`` by `aws sso login`).
|
|
170
|
+
We don't cache it ourselves yet — punt to a full re-acquire."""
|
|
171
|
+
raise CredentialExpired(
|
|
172
|
+
f"aws-sso: refresh not implemented yet — run "
|
|
173
|
+
f"`briar auth login --provider aws-sso --company {company}` to re-acquire"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def writes(cls, *, company: str) -> List[str]:
|
|
178
|
+
if not company:
|
|
179
|
+
return []
|
|
180
|
+
return [
|
|
181
|
+
CredEnv.AWS_KEY_ID.for_company(company),
|
|
182
|
+
CredEnv.AWS_SECRET.for_company(company),
|
|
183
|
+
CredEnv.AWS_SESSION.for_company(company),
|
|
184
|
+
CredEnv.AWS_REGION.for_company(company),
|
|
185
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""AWS static-access-key acquirer.
|
|
2
|
+
|
|
3
|
+
Prompts for AccessKeyId + SecretAccessKey + region. No session
|
|
4
|
+
token (use ``aws-sso`` or external aws-vault/granted for STS-backed
|
|
5
|
+
credentials with auto-rotation)."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
from briar.auth._acquirer import CredentialAcquirer, Credentials
|
|
12
|
+
from briar.auth._prompt import PromptIO
|
|
13
|
+
from briar.env_vars import CredEnv
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AwsStaticAcquirer(CredentialAcquirer):
|
|
17
|
+
kind = "aws-static"
|
|
18
|
+
display_name = "AWS static access key (paste)"
|
|
19
|
+
|
|
20
|
+
def acquire(self, *, company: str, prompt: PromptIO) -> Credentials:
|
|
21
|
+
if not company:
|
|
22
|
+
raise ValueError("aws-static: --company is required")
|
|
23
|
+
prompt.info("==> AWS static keys")
|
|
24
|
+
prompt.info(" Paste an IAM user's AccessKeyId + SecretAccessKey.")
|
|
25
|
+
prompt.info(" The key needs only the permissions briar's extractors call:")
|
|
26
|
+
prompt.info(" ec2:Describe* rds:Describe* ecs:List*+Describe*")
|
|
27
|
+
prompt.info(" lambda:List*+Get* logs:Describe* sqs:List*+Get*")
|
|
28
|
+
prompt.info(" sts:GetCallerIdentity")
|
|
29
|
+
|
|
30
|
+
kid = prompt.prompt(" AccessKeyId (AKIA…): ").strip()
|
|
31
|
+
secret = prompt.prompt(" SecretAccessKey: ", secret=True).strip()
|
|
32
|
+
region = prompt.prompt(" Default region [us-east-1]: ").strip() or "us-east-1"
|
|
33
|
+
if not (kid and secret):
|
|
34
|
+
raise ValueError("aws-static: key id and secret required")
|
|
35
|
+
|
|
36
|
+
return Credentials(
|
|
37
|
+
provider_kind=self.kind,
|
|
38
|
+
entries={
|
|
39
|
+
CredEnv.AWS_KEY_ID.for_company(company): kid,
|
|
40
|
+
CredEnv.AWS_SECRET.for_company(company): secret,
|
|
41
|
+
CredEnv.AWS_REGION.for_company(company): region,
|
|
42
|
+
},
|
|
43
|
+
metadata={"auth_mode": "static-iam-user"},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def writes(cls, *, company: str) -> List[str]:
|
|
48
|
+
if not company:
|
|
49
|
+
return []
|
|
50
|
+
return [
|
|
51
|
+
CredEnv.AWS_KEY_ID.for_company(company),
|
|
52
|
+
CredEnv.AWS_SECRET.for_company(company),
|
|
53
|
+
CredEnv.AWS_REGION.for_company(company),
|
|
54
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Bitbucket Cloud app-password acquirer.
|
|
2
|
+
|
|
3
|
+
Walks the user through app-password generation at
|
|
4
|
+
bitbucket.org → personal settings → app passwords, then prompts
|
|
5
|
+
for workspace + username + paste of the generated password.
|
|
6
|
+
|
|
7
|
+
Stores ``BITBUCKET_<COMPANY>_USERNAME``, ``BITBUCKET_<COMPANY>_APP_PASSWORD``,
|
|
8
|
+
``BITBUCKET_<COMPANY>_WORKSPACE`` (per-company because app passwords
|
|
9
|
+
are tied to a user × workspace pair, unlike GitHub PATs)."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import List
|
|
14
|
+
|
|
15
|
+
from briar.auth._acquirer import CredentialAcquirer, Credentials
|
|
16
|
+
from briar.auth._prompt import PromptIO
|
|
17
|
+
from briar.env_vars import CredEnv
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_SETTINGS_URL = "https://bitbucket.org/account/settings/app-passwords/"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BitbucketAppPasswordAcquirer(CredentialAcquirer):
|
|
24
|
+
kind = "bitbucket-app-password"
|
|
25
|
+
display_name = "Bitbucket Cloud app password (paste)"
|
|
26
|
+
|
|
27
|
+
def acquire(self, *, company: str, prompt: PromptIO) -> Credentials:
|
|
28
|
+
if not company:
|
|
29
|
+
raise ValueError("bitbucket-app-password: --company is required")
|
|
30
|
+
prompt.info("==> Bitbucket Cloud — app password")
|
|
31
|
+
prompt.info(f" 1. Open {_SETTINGS_URL}")
|
|
32
|
+
prompt.info(" 2. Click 'Create app password'")
|
|
33
|
+
prompt.info(" 3. Required scopes: Repositories: Read + Write, Pull requests: Read + Write")
|
|
34
|
+
prompt.info(" 4. Copy the generated password (you only see it once)")
|
|
35
|
+
prompt.open_url(_SETTINGS_URL)
|
|
36
|
+
|
|
37
|
+
workspace = prompt.prompt(" Bitbucket workspace slug: ").strip()
|
|
38
|
+
username = prompt.prompt(" Bitbucket username (NOT email): ").strip()
|
|
39
|
+
password = prompt.prompt(" paste app password: ", secret=True).strip()
|
|
40
|
+
if not (workspace and username and password):
|
|
41
|
+
raise ValueError("bitbucket-app-password: all three fields required")
|
|
42
|
+
|
|
43
|
+
return Credentials(
|
|
44
|
+
provider_kind=self.kind,
|
|
45
|
+
entries={
|
|
46
|
+
CredEnv.BITBUCKET_WORKSPACE.for_company(company): workspace,
|
|
47
|
+
CredEnv.BITBUCKET_USERNAME.for_company(company): username,
|
|
48
|
+
CredEnv.BITBUCKET_APP_PASSWORD.for_company(company): password,
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def writes(cls, *, company: str) -> List[str]:
|
|
54
|
+
if not company:
|
|
55
|
+
return []
|
|
56
|
+
return [
|
|
57
|
+
CredEnv.BITBUCKET_WORKSPACE.for_company(company),
|
|
58
|
+
CredEnv.BITBUCKET_USERNAME.for_company(company),
|
|
59
|
+
CredEnv.BITBUCKET_APP_PASSWORD.for_company(company),
|
|
60
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""GitHub OAuth device flow acquirer.
|
|
2
|
+
|
|
3
|
+
Implements the device-flow grant against GitHub's OAuth endpoints
|
|
4
|
+
(``github.com/login/device/code`` + ``github.com/login/oauth/access_token``).
|
|
5
|
+
The user visits the verification URL, enters a short code, and the
|
|
6
|
+
CLI polls until authorisation completes. The CLI never sees the
|
|
7
|
+
password — same security shape as ``gh auth login``.
|
|
8
|
+
|
|
9
|
+
Requires an OAuth App's client_id. Env var: ``BRIAR_GITHUB_CLIENT_ID``
|
|
10
|
+
(global; one OAuth app per briar install). To create one:
|
|
11
|
+
https://github.com/settings/applications/new — set "Device flow"
|
|
12
|
+
to enabled, callback URL can be anything (unused for device flow).
|
|
13
|
+
|
|
14
|
+
Stores ``GITHUB_TOKEN``."""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import urllib.request
|
|
23
|
+
from typing import List
|
|
24
|
+
|
|
25
|
+
from briar.auth._acquirer import CredentialAcquirer, Credentials
|
|
26
|
+
from briar.auth._prompt import PromptIO
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_DEVICE_CODE_URL = "https://github.com/login/device/code"
|
|
32
|
+
_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
|
33
|
+
_DEFAULT_SCOPES = "repo,read:org"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _post_form(url: str, fields: dict) -> dict:
|
|
37
|
+
"""POST application/x-www-form-urlencoded with Accept: application/json.
|
|
38
|
+
Returns parsed JSON. Raises on non-2xx."""
|
|
39
|
+
data = urllib.parse.urlencode(fields).encode("utf-8")
|
|
40
|
+
req = urllib.request.Request(
|
|
41
|
+
url,
|
|
42
|
+
data=data,
|
|
43
|
+
headers={"Accept": "application/json", "User-Agent": "briar-cli"},
|
|
44
|
+
method="POST",
|
|
45
|
+
)
|
|
46
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
47
|
+
return json.loads(resp.read())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GithubDeviceAcquirer(CredentialAcquirer):
|
|
51
|
+
kind = "github-device"
|
|
52
|
+
display_name = "GitHub OAuth device flow"
|
|
53
|
+
|
|
54
|
+
def acquire(self, *, company: str, prompt: PromptIO) -> Credentials:
|
|
55
|
+
client_id = os.environ.get("BRIAR_GITHUB_CLIENT_ID", "").strip()
|
|
56
|
+
if not client_id:
|
|
57
|
+
raise RuntimeError(
|
|
58
|
+
"github-device: BRIAR_GITHUB_CLIENT_ID env var required. "
|
|
59
|
+
"Register an OAuth App at https://github.com/settings/applications/new "
|
|
60
|
+
"with 'Device flow' enabled, then export the client_id."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Step 1: request a device code.
|
|
64
|
+
try:
|
|
65
|
+
init = _post_form(
|
|
66
|
+
_DEVICE_CODE_URL,
|
|
67
|
+
{"client_id": client_id, "scope": _DEFAULT_SCOPES},
|
|
68
|
+
)
|
|
69
|
+
except Exception as exc: # noqa: BLE001
|
|
70
|
+
raise RuntimeError(f"github-device: device-code request failed: {exc}")
|
|
71
|
+
|
|
72
|
+
device_code = init.get("device_code") or ""
|
|
73
|
+
user_code = init.get("user_code") or ""
|
|
74
|
+
verification_uri = init.get("verification_uri") or "https://github.com/login/device"
|
|
75
|
+
interval = max(int(init.get("interval", 5)), 1)
|
|
76
|
+
expires_in = int(init.get("expires_in", 900))
|
|
77
|
+
|
|
78
|
+
if not (device_code and user_code):
|
|
79
|
+
raise RuntimeError(f"github-device: malformed device-code response: {init}")
|
|
80
|
+
|
|
81
|
+
# Step 2: prompt user.
|
|
82
|
+
prompt.info("==> GitHub OAuth — device flow")
|
|
83
|
+
prompt.info(f" 1. Open {verification_uri}")
|
|
84
|
+
prompt.info(f" 2. Enter code: {user_code}")
|
|
85
|
+
prompt.info(f" 3. Authorise the app (scopes: {_DEFAULT_SCOPES})")
|
|
86
|
+
prompt.info(f" polling every {interval}s for up to {expires_in}s …")
|
|
87
|
+
prompt.open_url(verification_uri)
|
|
88
|
+
|
|
89
|
+
# Step 3: poll the token endpoint.
|
|
90
|
+
def _poll_once():
|
|
91
|
+
try:
|
|
92
|
+
resp = _post_form(
|
|
93
|
+
_TOKEN_URL,
|
|
94
|
+
{
|
|
95
|
+
"client_id": client_id,
|
|
96
|
+
"device_code": device_code,
|
|
97
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
except Exception as exc: # noqa: BLE001
|
|
101
|
+
log.debug("github-device: poll error (will retry): %s", exc)
|
|
102
|
+
return None
|
|
103
|
+
err = resp.get("error", "")
|
|
104
|
+
# `authorization_pending` is the normal "user hasn't acted yet"
|
|
105
|
+
# response — keep polling. `slow_down` means double the interval
|
|
106
|
+
# (rare; servers rarely send it). Other errors abort.
|
|
107
|
+
if err == "authorization_pending":
|
|
108
|
+
return None
|
|
109
|
+
if err == "slow_down":
|
|
110
|
+
return None
|
|
111
|
+
if err:
|
|
112
|
+
raise RuntimeError(f"github-device: OAuth error: {err} — {resp.get('error_description', '')}")
|
|
113
|
+
token = resp.get("access_token", "")
|
|
114
|
+
return token or None
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
token = prompt.poll(every=interval, max_wait=expires_in, fn=_poll_once)
|
|
118
|
+
except TimeoutError:
|
|
119
|
+
raise RuntimeError("github-device: timed out waiting for authorisation")
|
|
120
|
+
|
|
121
|
+
return Credentials(
|
|
122
|
+
provider_kind=self.kind,
|
|
123
|
+
entries={"GITHUB_TOKEN": str(token)},
|
|
124
|
+
metadata={"scopes": _DEFAULT_SCOPES, "auth_mode": "oauth-device"},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def writes(cls, *, company: str) -> List[str]:
|
|
129
|
+
return ["GITHUB_TOKEN"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""GitHub PAT (Personal Access Token) acquirer.
|
|
2
|
+
|
|
3
|
+
Walks the user through manual token generation on github.com →
|
|
4
|
+
settings → developer settings → personal access tokens, then
|
|
5
|
+
prompts for the paste. Stores ``GITHUB_TOKEN`` (no per-company
|
|
6
|
+
suffix — GitHub PATs are workspace-wide in this codebase)."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import List
|
|
11
|
+
|
|
12
|
+
from briar.auth._acquirer import CredentialAcquirer, Credentials
|
|
13
|
+
from briar.auth._prompt import PromptIO
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_TOKENS_URL = "https://github.com/settings/tokens"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GithubPatAcquirer(CredentialAcquirer):
|
|
20
|
+
kind = "github-pat"
|
|
21
|
+
display_name = "GitHub Personal Access Token (paste)"
|
|
22
|
+
|
|
23
|
+
def acquire(self, *, company: str, prompt: PromptIO) -> Credentials:
|
|
24
|
+
prompt.info("==> GitHub PAT — manual token generation")
|
|
25
|
+
prompt.info(f" 1. Open {_TOKENS_URL}")
|
|
26
|
+
prompt.info(" 2. Click 'Generate new token (classic)'")
|
|
27
|
+
prompt.info(" 3. Required scopes:")
|
|
28
|
+
prompt.info(" repo (read PRs, push commits)")
|
|
29
|
+
prompt.info(" read:org (org membership for filters)")
|
|
30
|
+
prompt.info(" 4. Set expiration to 90 days (or your org policy)")
|
|
31
|
+
prompt.info(" 5. Copy the token (ghp_… or github_pat_…)")
|
|
32
|
+
prompt.open_url(_TOKENS_URL)
|
|
33
|
+
token = prompt.prompt(" paste token: ", secret=True).strip()
|
|
34
|
+
if not token:
|
|
35
|
+
raise ValueError("github-pat: empty token")
|
|
36
|
+
return Credentials(
|
|
37
|
+
provider_kind=self.kind,
|
|
38
|
+
entries={"GITHUB_TOKEN": token},
|
|
39
|
+
metadata={"label": f"briar-{company or 'default'}"},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def writes(cls, *, company: str) -> List[str]:
|
|
44
|
+
return ["GITHUB_TOKEN"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Infisical machine-identity acquirer — "log into Infisical".
|
|
2
|
+
|
|
3
|
+
This is the bootstrap-the-bootstrap flow: every other store backend
|
|
4
|
+
authenticates with credentials the operator already has (AWS keys
|
|
5
|
+
in their ~/.aws, Vault token in their shell, GitHub PAT generated
|
|
6
|
+
on github.com). Infisical's machine identity is generated FROM
|
|
7
|
+
within Infisical's web UI, so we walk the operator through that.
|
|
8
|
+
|
|
9
|
+
The result is THREE values pasted into briar's local store (the
|
|
10
|
+
``envfile`` backend by default — usually ``~/.config/briar/secrets.env``
|
|
11
|
+
on a laptop). Subsequent processes that load that file then have
|
|
12
|
+
``INFISICAL_CLIENT_ID/_SECRET/_PROJECT_ID`` in env, so both
|
|
13
|
+
``InfisicalBootstrap`` (startup hydrate) and ``InfisicalStore``
|
|
14
|
+
(per-name read/write) can talk to Infisical.
|
|
15
|
+
|
|
16
|
+
Once these are set, ``briar auth login --provider github-pat
|
|
17
|
+
--store infisical`` writes the acquired GitHub PAT INTO Infisical,
|
|
18
|
+
not the local envfile. Same with every other acquirer."""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import List
|
|
23
|
+
|
|
24
|
+
from briar.auth._acquirer import CredentialAcquirer, Credentials, DestinationPolicy
|
|
25
|
+
from briar.auth._prompt import PromptIO
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_MACHINE_IDENTITY_URL = "https://app.infisical.com/personal-settings/machine-identities"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InfisicalAcquirer(CredentialAcquirer):
|
|
32
|
+
kind = "infisical"
|
|
33
|
+
display_name = "Infisical machine identity (paste)"
|
|
34
|
+
# The captured credentials are how briar talks to Infisical itself
|
|
35
|
+
# — they cannot be stored INSIDE Infisical (chicken-and-egg). The
|
|
36
|
+
# CLI forces --store=envfile when this policy is set.
|
|
37
|
+
destination_policy = DestinationPolicy.BOOTSTRAP_LOCAL
|
|
38
|
+
|
|
39
|
+
def acquire(self, *, company: str, prompt: PromptIO) -> Credentials:
|
|
40
|
+
prompt.info("==> Infisical machine identity")
|
|
41
|
+
prompt.info(" This is the bootstrap step — once these three values land in your")
|
|
42
|
+
prompt.info(" local store, briar can subsequently use Infisical as a CredentialStore")
|
|
43
|
+
prompt.info(" (`briar auth login ... --store infisical`).")
|
|
44
|
+
prompt.info("")
|
|
45
|
+
prompt.info(f" 1. Open {_MACHINE_IDENTITY_URL}")
|
|
46
|
+
prompt.info(" 2. Click 'Create Identity', name it `briar-cli`")
|
|
47
|
+
prompt.info(" 3. Select 'Universal Auth' as the auth method → create")
|
|
48
|
+
prompt.info(" 4. Open the new identity → 'Create Client Secret'")
|
|
49
|
+
prompt.info(" 5. Add the identity to your project: project Settings → Access Control →")
|
|
50
|
+
prompt.info(" Machine Identities → Add identity → grant 'Secrets / Read' + 'Write'")
|
|
51
|
+
prompt.info(" 6. Copy: Client ID + Client Secret (you only see the secret once) + Project ID")
|
|
52
|
+
prompt.open_url(_MACHINE_IDENTITY_URL)
|
|
53
|
+
|
|
54
|
+
client_id = prompt.prompt(" INFISICAL_CLIENT_ID (UUID): ").strip()
|
|
55
|
+
client_secret = prompt.prompt(" INFISICAL_CLIENT_SECRET: ", secret=True).strip()
|
|
56
|
+
project_id = prompt.prompt(" INFISICAL_PROJECT_ID (UUID, project Settings → General): ").strip()
|
|
57
|
+
env_slug = prompt.prompt(" INFISICAL_ENV slug [prod]: ").strip() or "prod"
|
|
58
|
+
host = prompt.prompt(" INFISICAL_HOST [https://app.infisical.com]: ").strip() or "https://app.infisical.com"
|
|
59
|
+
|
|
60
|
+
if not (client_id and client_secret and project_id):
|
|
61
|
+
raise ValueError("infisical: client_id + client_secret + project_id all required")
|
|
62
|
+
|
|
63
|
+
return Credentials(
|
|
64
|
+
provider_kind=self.kind,
|
|
65
|
+
entries={
|
|
66
|
+
"INFISICAL_CLIENT_ID": client_id,
|
|
67
|
+
"INFISICAL_CLIENT_SECRET": client_secret,
|
|
68
|
+
"INFISICAL_PROJECT_ID": project_id,
|
|
69
|
+
"INFISICAL_ENV": env_slug,
|
|
70
|
+
"INFISICAL_HOST": host,
|
|
71
|
+
},
|
|
72
|
+
metadata={"auth_mode": "universal-auth-machine-identity"},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def writes(cls, *, company: str) -> List[str]:
|
|
77
|
+
# The three required vars (env + host are listed in acquire() but
|
|
78
|
+
# not surfaced here — they're operational defaults the doctor
|
|
79
|
+
# doesn't need to flag as missing).
|
|
80
|
+
return ["INFISICAL_CLIENT_ID", "INFISICAL_CLIENT_SECRET", "INFISICAL_PROJECT_ID"]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Jira browser-session-cookie acquirer.
|
|
2
|
+
|
|
3
|
+
Walks the user through DevTools cookie extraction. For tenants where
|
|
4
|
+
the user cannot generate an API token (SSO policy, restricted
|
|
5
|
+
account types).
|
|
6
|
+
|
|
7
|
+
Stores ``JIRA_<COMPANY>_URL``, ``JIRA_<COMPANY>_TENANT_SESSION_TOKEN``,
|
|
8
|
+
and sets ``JIRA_<COMPANY>_AUTH_KIND=session``. ``cloud.session.token``
|
|
9
|
+
+ ``atlassian.xsrf.token`` are accepted as optional extras.
|
|
10
|
+
|
|
11
|
+
Parses the JWT payload to record ``expires_at`` so the CLI can warn
|
|
12
|
+
about the upcoming rotation."""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import List, Optional
|
|
21
|
+
|
|
22
|
+
from briar.auth._acquirer import CredentialAcquirer, Credentials
|
|
23
|
+
from briar.auth._prompt import PromptIO
|
|
24
|
+
from briar.env_vars import CredEnv
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _decode_jwt_exp(jwt: str) -> Optional[datetime]:
|
|
31
|
+
"""Return the ``exp`` claim as a tz-aware UTC datetime, or None
|
|
32
|
+
if the token isn't a 3-segment JWT or doesn't carry an exp.
|
|
33
|
+
Silently tolerates malformed segments — the cookie is the source
|
|
34
|
+
of truth; expiry detection is a nice-to-have."""
|
|
35
|
+
parts = jwt.split(".")
|
|
36
|
+
if len(parts) != 3:
|
|
37
|
+
return None
|
|
38
|
+
payload = parts[1]
|
|
39
|
+
padded = payload + "=" * (-len(payload) % 4)
|
|
40
|
+
try:
|
|
41
|
+
claims = json.loads(base64.urlsafe_b64decode(padded))
|
|
42
|
+
except Exception: # noqa: BLE001
|
|
43
|
+
return None
|
|
44
|
+
exp = claims.get("exp")
|
|
45
|
+
if not isinstance(exp, int):
|
|
46
|
+
return None
|
|
47
|
+
return datetime.fromtimestamp(exp, tz=timezone.utc)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class JiraSessionAcquirer(CredentialAcquirer):
|
|
51
|
+
kind = "jira-session"
|
|
52
|
+
display_name = "Jira browser session cookie (DevTools paste)"
|
|
53
|
+
|
|
54
|
+
def acquire(self, *, company: str, prompt: PromptIO) -> Credentials:
|
|
55
|
+
if not company:
|
|
56
|
+
raise ValueError("jira-session: --company is required")
|
|
57
|
+
|
|
58
|
+
prompt.info("==> Jira browser-session cookie")
|
|
59
|
+
prompt.info(" 1. Log into your Jira tenant in the browser (https://<org>.atlassian.net)")
|
|
60
|
+
prompt.info(" 2. DevTools → Application → Cookies → <your tenant>")
|
|
61
|
+
prompt.info(" 3. Click the `tenant.session.token` row")
|
|
62
|
+
prompt.info(" 4. DOUBLE-CLICK the Value cell and copy the full value (starts with `eyJ`)")
|
|
63
|
+
prompt.info(" (drag-selecting often truncates the start — use double-click)")
|
|
64
|
+
|
|
65
|
+
url = prompt.prompt(" Jira URL (https://<org>.atlassian.net): ").strip().rstrip("/")
|
|
66
|
+
tenant_token = prompt.prompt(" paste tenant.session.token: ", secret=True).strip()
|
|
67
|
+
cloud_token = prompt.prompt(" paste cloud.session.token (optional, ENTER to skip): ", secret=True).strip()
|
|
68
|
+
xsrf_token = prompt.prompt(" paste atlassian.xsrf.token (optional, ENTER to skip): ", secret=True).strip()
|
|
69
|
+
|
|
70
|
+
if not (url and tenant_token):
|
|
71
|
+
raise ValueError("jira-session: URL + tenant.session.token required (at minimum)")
|
|
72
|
+
|
|
73
|
+
entries = {
|
|
74
|
+
CredEnv.JIRA_URL.for_company(company): url,
|
|
75
|
+
CredEnv.JIRA_TENANT_SESSION_TOKEN.for_company(company): tenant_token,
|
|
76
|
+
CredEnv.JIRA_AUTH_KIND.for_company(company): "session",
|
|
77
|
+
}
|
|
78
|
+
if cloud_token:
|
|
79
|
+
entries[CredEnv.JIRA_SESSION_TOKEN.for_company(company)] = cloud_token
|
|
80
|
+
if xsrf_token:
|
|
81
|
+
entries[CredEnv.JIRA_XSRF_TOKEN.for_company(company)] = xsrf_token
|
|
82
|
+
|
|
83
|
+
expires_at = _decode_jwt_exp(tenant_token)
|
|
84
|
+
return Credentials(
|
|
85
|
+
provider_kind=self.kind,
|
|
86
|
+
entries=entries,
|
|
87
|
+
expires_at=expires_at,
|
|
88
|
+
metadata={"auth_mode": "browser-session"},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def writes(cls, *, company: str) -> List[str]:
|
|
93
|
+
if not company:
|
|
94
|
+
return []
|
|
95
|
+
# The mandatory writes; cloud/xsrf are optional and not listed
|
|
96
|
+
# (doctor reports them as nice-to-have via JiraSessionAuth's
|
|
97
|
+
# required_env_vars).
|
|
98
|
+
return [
|
|
99
|
+
CredEnv.JIRA_URL.for_company(company),
|
|
100
|
+
CredEnv.JIRA_TENANT_SESSION_TOKEN.for_company(company),
|
|
101
|
+
CredEnv.JIRA_AUTH_KIND.for_company(company),
|
|
102
|
+
]
|