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,63 @@
|
|
|
1
|
+
"""`CredentialStore` — Strategy contract for credential resolution.
|
|
2
|
+
|
|
3
|
+
Today, every cred is read directly from process env vars (via
|
|
4
|
+
``CredEnv``). This abstraction lets you keep the same call sites
|
|
5
|
+
(``store.read(env_name)``) while swapping the backing source —
|
|
6
|
+
EnvFile (today), AWS Secrets Manager, SSM Parameter Store, Vault.
|
|
7
|
+
|
|
8
|
+
Four verbs:
|
|
9
|
+
- ``read(name)``: look up one var by canonical name (e.g.
|
|
10
|
+
``AWS_ACME_ACCESS_KEY_ID``). Returns ``""`` if not found —
|
|
11
|
+
callers check truthiness.
|
|
12
|
+
- ``list()``: enumerate names of all credentials known to the store
|
|
13
|
+
(used by ``briar secrets doctor`` to report set/missing).
|
|
14
|
+
- ``fingerprint(name)``: md5 of the stored value (for rotation detection
|
|
15
|
+
without exposing the value itself).
|
|
16
|
+
- ``expires_at(name)``: parse expiration for time-bound creds (AWS STS
|
|
17
|
+
session tokens). Returns ISO-8601 string or ``""``."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from abc import ABC, abstractmethod
|
|
22
|
+
from typing import ClassVar, List
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CredentialStore(ABC):
|
|
26
|
+
kind: ClassVar[str] = ""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def read(self, name: str) -> str:
|
|
30
|
+
"""Return the value for `name`, or ``""`` if missing."""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def write(self, name: str, value: str) -> None:
|
|
34
|
+
"""Persist a credential. Overwrites existing values of the
|
|
35
|
+
same name. Used by ``briar auth login`` after a successful
|
|
36
|
+
acquire. Failures raise — callers propagate so the operator
|
|
37
|
+
sees the underlying SDK error."""
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def delete(self, name: str) -> bool:
|
|
41
|
+
"""Remove a credential. Returns True if a value existed and was
|
|
42
|
+
removed, False if the name was unknown. Used by
|
|
43
|
+
``briar auth logout``."""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def list(self) -> List[str]:
|
|
47
|
+
"""Enumerate every credential name this store knows."""
|
|
48
|
+
|
|
49
|
+
def fingerprint(self, name: str) -> str:
|
|
50
|
+
"""Default: md5 of the value. Override for stores that can
|
|
51
|
+
compute hashes server-side."""
|
|
52
|
+
import hashlib
|
|
53
|
+
|
|
54
|
+
value = self.read(name)
|
|
55
|
+
if not value:
|
|
56
|
+
return ""
|
|
57
|
+
return hashlib.md5(value.encode("utf-8")).hexdigest()
|
|
58
|
+
|
|
59
|
+
def expires_at(self, name: str) -> str:
|
|
60
|
+
"""Default: no expiration tracking. AWS STS session tokens
|
|
61
|
+
encode expiry inside the credential itself — overridden by
|
|
62
|
+
the EnvFile / AwsSecretsManager stores."""
|
|
63
|
+
return ""
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""AWS Secrets Manager `CredentialStore`.
|
|
2
|
+
|
|
3
|
+
Reads ``briar/<name>`` secrets at first call and caches in-memory for
|
|
4
|
+
the process lifetime (SecretsManager charges per API call). Region
|
|
5
|
+
comes from ``AWS_REGION`` env var or the boto3 default; auth uses the
|
|
6
|
+
ambient credential chain (IAM role on EC2, SSO profile locally,
|
|
7
|
+
static keys via env).
|
|
8
|
+
|
|
9
|
+
Composite secrets (a single ``SecretId`` whose ``SecretString`` is a
|
|
10
|
+
JSON object) are also supported via the ``key`` argument convention:
|
|
11
|
+
``store.read("BITBUCKET_ACME_USERNAME")`` → looks up secret
|
|
12
|
+
``briar/BITBUCKET_ACME_USERNAME``; if the SecretString parses as
|
|
13
|
+
JSON, the ``value`` key (or ``BITBUCKET_ACME_USERNAME``) wins."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Dict, List
|
|
20
|
+
|
|
21
|
+
from briar.credentials._store import CredentialStore
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AwsSecretsManagerStore(CredentialStore):
|
|
28
|
+
kind = "aws-secretsmanager"
|
|
29
|
+
PREFIX = "briar/"
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self._client = None
|
|
33
|
+
self._cache: Dict[str, str] = {}
|
|
34
|
+
|
|
35
|
+
def _make_client(self):
|
|
36
|
+
if self._client is not None:
|
|
37
|
+
return self._client
|
|
38
|
+
import boto3
|
|
39
|
+
|
|
40
|
+
self._client = boto3.client("secretsmanager")
|
|
41
|
+
return self._client
|
|
42
|
+
|
|
43
|
+
def read(self, name: str) -> str:
|
|
44
|
+
if name in self._cache:
|
|
45
|
+
return self._cache[name]
|
|
46
|
+
client = self._make_client()
|
|
47
|
+
try:
|
|
48
|
+
resp = client.get_secret_value(SecretId=f"{self.PREFIX}{name}")
|
|
49
|
+
except Exception as exc: # noqa: BLE001
|
|
50
|
+
log.debug("aws-secretsmanager read miss name=%s err=%s", name, exc)
|
|
51
|
+
self._cache[name] = ""
|
|
52
|
+
return ""
|
|
53
|
+
value = resp.get("SecretString") or ""
|
|
54
|
+
# Composite-secret support: if SecretString parses as JSON,
|
|
55
|
+
# look for `{value}` or the bare key. Otherwise treat as scalar.
|
|
56
|
+
try:
|
|
57
|
+
parsed = json.loads(value)
|
|
58
|
+
if isinstance(parsed, dict):
|
|
59
|
+
value = str(parsed.get("value") or parsed.get(name) or value)
|
|
60
|
+
except (ValueError, TypeError):
|
|
61
|
+
pass
|
|
62
|
+
self._cache[name] = value
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
def write(self, name: str, value: str) -> None:
|
|
66
|
+
"""Upsert via PutSecretValue; falls through to CreateSecret on
|
|
67
|
+
first-time write."""
|
|
68
|
+
client = self._make_client()
|
|
69
|
+
secret_id = f"{self.PREFIX}{name}"
|
|
70
|
+
try:
|
|
71
|
+
client.put_secret_value(SecretId=secret_id, SecretString=value)
|
|
72
|
+
except Exception as exc: # noqa: BLE001
|
|
73
|
+
if "resourcenotfoundexception" in str(exc).lower() or "not found" in str(exc).lower():
|
|
74
|
+
client.create_secret(Name=secret_id, SecretString=value)
|
|
75
|
+
else:
|
|
76
|
+
log.exception("aws-secretsmanager write name=%s", name)
|
|
77
|
+
raise
|
|
78
|
+
self._cache[name] = value
|
|
79
|
+
|
|
80
|
+
def delete(self, name: str) -> bool:
|
|
81
|
+
"""Force-delete with no recovery window."""
|
|
82
|
+
client = self._make_client()
|
|
83
|
+
secret_id = f"{self.PREFIX}{name}"
|
|
84
|
+
try:
|
|
85
|
+
client.delete_secret(SecretId=secret_id, ForceDeleteWithoutRecovery=True)
|
|
86
|
+
except Exception as exc: # noqa: BLE001
|
|
87
|
+
if "resourcenotfoundexception" in str(exc).lower():
|
|
88
|
+
return False
|
|
89
|
+
raise
|
|
90
|
+
self._cache.pop(name, None)
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
def list(self) -> List[str]:
|
|
94
|
+
client = self._make_client()
|
|
95
|
+
out: List[str] = []
|
|
96
|
+
paginator = client.get_paginator("list_secrets")
|
|
97
|
+
for page in paginator.paginate():
|
|
98
|
+
for entry in page.get("SecretList", []) or []:
|
|
99
|
+
full = entry.get("Name") or ""
|
|
100
|
+
if full.startswith(self.PREFIX):
|
|
101
|
+
out.append(full[len(self.PREFIX) :])
|
|
102
|
+
return sorted(out)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""`EnvFileStore` — thin wrapper over process env vars (which is
|
|
2
|
+
where `/etc/briar/secrets.env` lands once systemd reads it via
|
|
3
|
+
``EnvironmentFile=``).
|
|
4
|
+
|
|
5
|
+
This is the only `CredentialStore` backend that needs to work today.
|
|
6
|
+
It exposes the existing env-var surface through the new abstraction
|
|
7
|
+
so `briar secrets doctor` and any future store-backed code can use
|
|
8
|
+
one API regardless of where credentials live."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List
|
|
17
|
+
|
|
18
|
+
from briar.credentials._store import CredentialStore
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Where the secrets file lives. Resolution chain (first match wins):
|
|
25
|
+
# 1. $BRIAR_SECRETS_FILE — explicit operator override
|
|
26
|
+
# 2. /etc/briar/secrets.env — if already present (droplet convention,
|
|
27
|
+
# systemd reads it via EnvironmentFile=)
|
|
28
|
+
# 3. $XDG_CONFIG_HOME/briar/secrets.env (or ~/.config/briar/secrets.env)
|
|
29
|
+
# — laptop default (XDG-compliant)
|
|
30
|
+
# The chain "just works" on both deploy shapes without requiring the
|
|
31
|
+
# laptop user to set BRIAR_SECRETS_FILE manually.
|
|
32
|
+
def _secrets_path() -> Path:
|
|
33
|
+
explicit = os.environ.get("BRIAR_SECRETS_FILE", "").strip()
|
|
34
|
+
if explicit:
|
|
35
|
+
return Path(explicit)
|
|
36
|
+
system = Path("/etc/briar/secrets.env")
|
|
37
|
+
if system.exists():
|
|
38
|
+
return system
|
|
39
|
+
base_str = os.environ.get("XDG_CONFIG_HOME", "").strip()
|
|
40
|
+
base = Path(base_str) if base_str else Path.home() / ".config"
|
|
41
|
+
return base / "briar" / "secrets.env"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validate_name(name: str) -> None:
|
|
48
|
+
if not _NAME_RE.match(name):
|
|
49
|
+
raise ValueError(f"EnvFileStore: invalid env-var name {name!r} — must match {_NAME_RE.pattern}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Canonical credential name prefixes — used by `list()` to enumerate
|
|
53
|
+
# which env vars are "credentials" vs unrelated process state.
|
|
54
|
+
# Updating CredEnv? Update this list too.
|
|
55
|
+
_KNOWN_PREFIXES: tuple = (
|
|
56
|
+
"AWS_",
|
|
57
|
+
"GITHUB_",
|
|
58
|
+
"BITBUCKET_",
|
|
59
|
+
"JIRA_",
|
|
60
|
+
"LINEAR_",
|
|
61
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
62
|
+
"ANTHROPIC_API_KEY",
|
|
63
|
+
"OPENAI_API_KEY",
|
|
64
|
+
"GEMINI_API_KEY",
|
|
65
|
+
"BRIAR_DATABASE_URL",
|
|
66
|
+
"TELEGRAM_",
|
|
67
|
+
"SLACK_",
|
|
68
|
+
"SMTP_",
|
|
69
|
+
"EMAIL_",
|
|
70
|
+
"PAGERDUTY_",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class EnvFileStore(CredentialStore):
|
|
75
|
+
kind = "envfile"
|
|
76
|
+
|
|
77
|
+
def read(self, name: str) -> str:
|
|
78
|
+
return os.environ.get(name, "")
|
|
79
|
+
|
|
80
|
+
def write(self, name: str, value: str) -> None:
|
|
81
|
+
"""Update ``os.environ`` for the running process AND persist
|
|
82
|
+
durably to the secrets file. Atomic via temp-file + rename.
|
|
83
|
+
|
|
84
|
+
Idempotent: a line with the same KEY is replaced in-place;
|
|
85
|
+
new keys are appended at the end.
|
|
86
|
+
|
|
87
|
+
Parent directory is auto-created (``mkdir(parents=True,
|
|
88
|
+
exist_ok=True)``) — the XDG default path
|
|
89
|
+
(``~/.config/briar/``) doesn't exist on first use.
|
|
90
|
+
|
|
91
|
+
File-write failures **raise** ``OSError`` AFTER ``os.environ``
|
|
92
|
+
has been updated. Two reasons: (1) the calling process still
|
|
93
|
+
benefits from the in-memory update; (2) the caller (typically
|
|
94
|
+
``briar auth login``) needs the exception so the per-key
|
|
95
|
+
ok/FAIL summary reflects what was actually durable. Previous
|
|
96
|
+
behaviour was to swallow the error and lie — fixed."""
|
|
97
|
+
_validate_name(name)
|
|
98
|
+
os.environ[name] = value
|
|
99
|
+
|
|
100
|
+
path = _secrets_path()
|
|
101
|
+
try:
|
|
102
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
if not path.exists():
|
|
104
|
+
path.touch(mode=0o600)
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
raise OSError(f"envfile-store: could not prepare {path}: {exc}") from exc
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
existing = path.read_text()
|
|
110
|
+
except OSError as exc:
|
|
111
|
+
raise OSError(f"envfile-store: could not read {path}: {exc}") from exc
|
|
112
|
+
|
|
113
|
+
new_line = f"{name}={value}\n"
|
|
114
|
+
# Match `KEY=value` (possibly with leading whitespace, possibly
|
|
115
|
+
# `export KEY=value`) so we replace in place rather than appending
|
|
116
|
+
# a duplicate that shadows the first one.
|
|
117
|
+
pattern = re.compile(rf"^(?:export\s+)?{re.escape(name)}=.*\n?", re.MULTILINE)
|
|
118
|
+
if pattern.search(existing):
|
|
119
|
+
updated = pattern.sub(new_line, existing, count=1)
|
|
120
|
+
else:
|
|
121
|
+
tail = "" if existing.endswith("\n") or not existing else "\n"
|
|
122
|
+
updated = existing + tail + new_line
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
126
|
+
tmp.write_text(updated)
|
|
127
|
+
os.chmod(tmp, 0o600)
|
|
128
|
+
tmp.replace(path)
|
|
129
|
+
except OSError as exc:
|
|
130
|
+
raise OSError(f"envfile-store: could not persist {name} to {path}: {exc}") from exc
|
|
131
|
+
|
|
132
|
+
def delete(self, name: str) -> bool:
|
|
133
|
+
"""Remove from both ``os.environ`` and the persisted file.
|
|
134
|
+
Returns True iff something was actually removed (from either
|
|
135
|
+
location). Logout-friendly: if neither has the value, return
|
|
136
|
+
False so the CLI can report "nothing to do"."""
|
|
137
|
+
_validate_name(name)
|
|
138
|
+
env_had = name in os.environ
|
|
139
|
+
if env_had:
|
|
140
|
+
del os.environ[name]
|
|
141
|
+
|
|
142
|
+
path = _secrets_path()
|
|
143
|
+
file_had = False
|
|
144
|
+
if path.exists():
|
|
145
|
+
try:
|
|
146
|
+
existing = path.read_text()
|
|
147
|
+
except OSError:
|
|
148
|
+
return env_had
|
|
149
|
+
pattern = re.compile(rf"^(?:export\s+)?{re.escape(name)}=.*\n?", re.MULTILINE)
|
|
150
|
+
if pattern.search(existing):
|
|
151
|
+
file_had = True
|
|
152
|
+
updated = pattern.sub("", existing, count=1)
|
|
153
|
+
try:
|
|
154
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
155
|
+
tmp.write_text(updated)
|
|
156
|
+
os.chmod(tmp, 0o600)
|
|
157
|
+
tmp.replace(path)
|
|
158
|
+
except OSError as exc:
|
|
159
|
+
log.warning("envfile-store: could not persist deletion of %s: %s", name, exc)
|
|
160
|
+
|
|
161
|
+
return env_had or file_had
|
|
162
|
+
|
|
163
|
+
def list(self) -> List[str]:
|
|
164
|
+
return sorted(k for k in os.environ if any(k.startswith(p) for p in _KNOWN_PREFIXES))
|
|
165
|
+
|
|
166
|
+
def expires_at(self, name: str) -> str:
|
|
167
|
+
"""AWS STS session tokens carry expiry inside the token itself,
|
|
168
|
+
but parsing that requires the STS GetSessionToken API. For
|
|
169
|
+
env-file creds we can't tell — return ``""`` and let the
|
|
170
|
+
operator rotate based on the local SSO session timeout."""
|
|
171
|
+
return ""
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Infisical `CredentialStore` — symmetric counterpart to
|
|
2
|
+
``InfisicalBootstrap``.
|
|
3
|
+
|
|
4
|
+
The Bootstrap is the BULK-HYDRATE side (pull every secret from a
|
|
5
|
+
project at process startup → into ``os.environ``). This Store is the
|
|
6
|
+
READ/WRITE-PER-NAME side (read/write/delete one secret on demand —
|
|
7
|
+
used by ``briar auth login --store infisical`` and any future
|
|
8
|
+
on-demand fetch).
|
|
9
|
+
|
|
10
|
+
Both share the same machine-identity credentials:
|
|
11
|
+
INFISICAL_CLIENT_ID + INFISICAL_CLIENT_SECRET + INFISICAL_PROJECT_ID
|
|
12
|
+
(+ optional INFISICAL_ENV, defaults to ``prod``;
|
|
13
|
+
+ optional INFISICAL_HOST, defaults to https://app.infisical.com)
|
|
14
|
+
|
|
15
|
+
These are the values an `InfisicalAcquirer` walks the operator
|
|
16
|
+
through capturing — "logging into Infisical" in the briar UX is
|
|
17
|
+
really "give briar the machine-identity credentials it needs to
|
|
18
|
+
talk to Infisical from now on".
|
|
19
|
+
|
|
20
|
+
Lazy-imports ``infisical_sdk`` so it's an opt-in extra:
|
|
21
|
+
``pip install briar-cli[infisical]``."""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import importlib
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
from typing import Any, Dict, List, Optional
|
|
29
|
+
|
|
30
|
+
from briar.credentials._store import CredentialStore
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _import_infisical_sdk() -> Optional[Any]:
|
|
37
|
+
try:
|
|
38
|
+
return importlib.import_module("infisical_sdk")
|
|
39
|
+
except ImportError:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class InfisicalStore(CredentialStore):
|
|
44
|
+
kind = "infisical"
|
|
45
|
+
DEFAULT_HOST = "https://app.infisical.com"
|
|
46
|
+
DEFAULT_ENV = "prod"
|
|
47
|
+
SECRET_PATH = "/" # KV-style flat namespace under the project
|
|
48
|
+
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
self._client_id = os.environ.get("INFISICAL_CLIENT_ID", "")
|
|
51
|
+
self._client_secret = os.environ.get("INFISICAL_CLIENT_SECRET", "")
|
|
52
|
+
self._project_id = os.environ.get("INFISICAL_PROJECT_ID", "")
|
|
53
|
+
self._env = os.environ.get("INFISICAL_ENV", self.DEFAULT_ENV)
|
|
54
|
+
self._host = os.environ.get("INFISICAL_HOST", self.DEFAULT_HOST)
|
|
55
|
+
self._client = None
|
|
56
|
+
self._cache: Dict[str, str] = {}
|
|
57
|
+
|
|
58
|
+
def _build_client(self):
|
|
59
|
+
"""Universal-auth bind. Same dance as InfisicalBootstrap so a
|
|
60
|
+
process that has both store + bootstrap pointed at the same
|
|
61
|
+
Infisical project doesn't double-authenticate."""
|
|
62
|
+
if self._client is not None:
|
|
63
|
+
return self._client
|
|
64
|
+
sdk = _import_infisical_sdk()
|
|
65
|
+
if sdk is None:
|
|
66
|
+
raise RuntimeError("infisical_sdk not installed — run `pip install briar-cli[infisical]`")
|
|
67
|
+
if not all((self._client_id, self._client_secret, self._project_id)):
|
|
68
|
+
raise RuntimeError(
|
|
69
|
+
"InfisicalStore: missing INFISICAL_CLIENT_ID + _CLIENT_SECRET + _PROJECT_ID "
|
|
70
|
+
"— run `briar auth login --provider infisical` to set them"
|
|
71
|
+
)
|
|
72
|
+
self._client = sdk.InfisicalSDKClient(host=self._host)
|
|
73
|
+
self._client.auth.universal_auth.login(
|
|
74
|
+
client_id=self._client_id,
|
|
75
|
+
client_secret=self._client_secret,
|
|
76
|
+
)
|
|
77
|
+
return self._client
|
|
78
|
+
|
|
79
|
+
# ── read side ────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
def read(self, name: str) -> str:
|
|
82
|
+
"""Silent miss when Infisical isn't configured — matches
|
|
83
|
+
``EnvFileStore`` semantics so the doctor can audit without
|
|
84
|
+
forcing every caller to install the extra."""
|
|
85
|
+
if name in self._cache:
|
|
86
|
+
return self._cache[name]
|
|
87
|
+
if not all((self._client_id, self._client_secret, self._project_id)):
|
|
88
|
+
self._cache[name] = ""
|
|
89
|
+
return ""
|
|
90
|
+
try:
|
|
91
|
+
client = self._build_client()
|
|
92
|
+
resp = client.secrets.get_secret_by_name(
|
|
93
|
+
secret_name=name,
|
|
94
|
+
project_id=self._project_id,
|
|
95
|
+
environment_slug=self._env,
|
|
96
|
+
secret_path=self.SECRET_PATH,
|
|
97
|
+
)
|
|
98
|
+
except Exception as exc: # noqa: BLE001 — translate to "missing" per the store contract
|
|
99
|
+
log.debug("infisical-store read miss name=%s err=%s", name, exc)
|
|
100
|
+
self._cache[name] = ""
|
|
101
|
+
return ""
|
|
102
|
+
# Tolerate both attribute + dict shapes (SDK versions differ).
|
|
103
|
+
value = getattr(resp, "secretValue", None)
|
|
104
|
+
if value is None and isinstance(resp, dict):
|
|
105
|
+
value = resp.get("secretValue") or (resp.get("secret") or {}).get("secretValue")
|
|
106
|
+
if value is None:
|
|
107
|
+
secret = getattr(resp, "secret", None)
|
|
108
|
+
value = getattr(secret, "secretValue", None) if secret is not None else None
|
|
109
|
+
value = str(value or "")
|
|
110
|
+
self._cache[name] = value
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
# ── write side ───────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
def write(self, name: str, value: str) -> None:
|
|
116
|
+
"""Upsert. Try ``update`` first — falls back to ``create`` if
|
|
117
|
+
the secret doesn't exist yet (the symmetric pattern used by
|
|
118
|
+
``AwsSecretsManagerStore.write``)."""
|
|
119
|
+
client = self._build_client()
|
|
120
|
+
try:
|
|
121
|
+
client.secrets.update_secret_by_name(
|
|
122
|
+
secret_name=name,
|
|
123
|
+
project_id=self._project_id,
|
|
124
|
+
environment_slug=self._env,
|
|
125
|
+
secret_path=self.SECRET_PATH,
|
|
126
|
+
secret_value=value,
|
|
127
|
+
)
|
|
128
|
+
except Exception as exc: # noqa: BLE001
|
|
129
|
+
msg = str(exc).lower()
|
|
130
|
+
if "not found" in msg or "notfound" in msg or "404" in msg:
|
|
131
|
+
client.secrets.create_secret_by_name(
|
|
132
|
+
secret_name=name,
|
|
133
|
+
project_id=self._project_id,
|
|
134
|
+
environment_slug=self._env,
|
|
135
|
+
secret_path=self.SECRET_PATH,
|
|
136
|
+
secret_value=value,
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
log.exception("infisical-store write name=%s", name)
|
|
140
|
+
raise
|
|
141
|
+
self._cache[name] = value
|
|
142
|
+
|
|
143
|
+
def delete(self, name: str) -> bool:
|
|
144
|
+
client = self._build_client()
|
|
145
|
+
try:
|
|
146
|
+
client.secrets.delete_secret_by_name(
|
|
147
|
+
secret_name=name,
|
|
148
|
+
project_id=self._project_id,
|
|
149
|
+
environment_slug=self._env,
|
|
150
|
+
secret_path=self.SECRET_PATH,
|
|
151
|
+
)
|
|
152
|
+
except Exception as exc: # noqa: BLE001
|
|
153
|
+
msg = str(exc).lower()
|
|
154
|
+
if "not found" in msg or "notfound" in msg or "404" in msg:
|
|
155
|
+
return False
|
|
156
|
+
raise
|
|
157
|
+
self._cache.pop(name, None)
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
# ── enumeration ──────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
def list(self) -> List[str]:
|
|
163
|
+
"""Empty list when not configured — symmetric to read()."""
|
|
164
|
+
if not all((self._client_id, self._client_secret, self._project_id)):
|
|
165
|
+
return []
|
|
166
|
+
try:
|
|
167
|
+
client = self._build_client()
|
|
168
|
+
resp = client.secrets.list_secrets(
|
|
169
|
+
project_id=self._project_id,
|
|
170
|
+
environment_slug=self._env,
|
|
171
|
+
secret_path=self.SECRET_PATH,
|
|
172
|
+
)
|
|
173
|
+
except Exception as exc: # noqa: BLE001
|
|
174
|
+
log.debug("infisical-store list failed err=%s", exc)
|
|
175
|
+
return []
|
|
176
|
+
names: List[str] = []
|
|
177
|
+
for s in (getattr(resp, "secrets", None) or []) if not isinstance(resp, dict) else (resp.get("secrets") or []):
|
|
178
|
+
n = getattr(s, "secretKey", None)
|
|
179
|
+
if n is None and isinstance(s, dict):
|
|
180
|
+
n = s.get("secretKey", "")
|
|
181
|
+
if n:
|
|
182
|
+
names.append(str(n))
|
|
183
|
+
return sorted(names)
|
briar/credentials/ssm.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""AWS SSM Parameter Store `CredentialStore`.
|
|
2
|
+
|
|
3
|
+
SSM is significantly cheaper than Secrets Manager (free for the first
|
|
4
|
+
~10k standard parameter ops/month). Use ``SecureString`` parameters
|
|
5
|
+
with KMS encryption — this store always passes ``WithDecryption=True``.
|
|
6
|
+
|
|
7
|
+
Convention: parameter names are prefixed with ``/briar/`` so a single
|
|
8
|
+
IAM policy can grant access by path."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Dict, List
|
|
14
|
+
|
|
15
|
+
from briar.credentials._store import CredentialStore
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SsmParameterStore(CredentialStore):
|
|
22
|
+
kind = "ssm"
|
|
23
|
+
PREFIX = "/briar/"
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._client = None
|
|
27
|
+
self._cache: Dict[str, str] = {}
|
|
28
|
+
|
|
29
|
+
def _make_client(self):
|
|
30
|
+
if self._client is not None:
|
|
31
|
+
return self._client
|
|
32
|
+
import boto3
|
|
33
|
+
|
|
34
|
+
self._client = boto3.client("ssm")
|
|
35
|
+
return self._client
|
|
36
|
+
|
|
37
|
+
def read(self, name: str) -> str:
|
|
38
|
+
if name in self._cache:
|
|
39
|
+
return self._cache[name]
|
|
40
|
+
client = self._make_client()
|
|
41
|
+
try:
|
|
42
|
+
resp = client.get_parameter(Name=f"{self.PREFIX}{name}", WithDecryption=True)
|
|
43
|
+
except Exception as exc: # noqa: BLE001
|
|
44
|
+
log.debug("ssm read miss name=%s err=%s", name, exc)
|
|
45
|
+
self._cache[name] = ""
|
|
46
|
+
return ""
|
|
47
|
+
value = str((resp.get("Parameter") or {}).get("Value") or "")
|
|
48
|
+
self._cache[name] = value
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
def write(self, name: str, value: str) -> None:
|
|
52
|
+
"""Upsert via PutParameter with Overwrite=True. SecureString
|
|
53
|
+
type forces KMS encryption (default key under the account)."""
|
|
54
|
+
client = self._make_client()
|
|
55
|
+
client.put_parameter(
|
|
56
|
+
Name=f"{self.PREFIX}{name}",
|
|
57
|
+
Value=value,
|
|
58
|
+
Type="SecureString",
|
|
59
|
+
Overwrite=True,
|
|
60
|
+
)
|
|
61
|
+
self._cache[name] = value
|
|
62
|
+
|
|
63
|
+
def delete(self, name: str) -> bool:
|
|
64
|
+
client = self._make_client()
|
|
65
|
+
try:
|
|
66
|
+
client.delete_parameter(Name=f"{self.PREFIX}{name}")
|
|
67
|
+
except Exception as exc: # noqa: BLE001
|
|
68
|
+
if "parameternotfound" in str(exc).lower():
|
|
69
|
+
return False
|
|
70
|
+
raise
|
|
71
|
+
self._cache.pop(name, None)
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
def list(self) -> List[str]:
|
|
75
|
+
client = self._make_client()
|
|
76
|
+
out: List[str] = []
|
|
77
|
+
paginator = client.get_paginator("get_parameters_by_path")
|
|
78
|
+
for page in paginator.paginate(Path=self.PREFIX, Recursive=True, WithDecryption=False):
|
|
79
|
+
for entry in page.get("Parameters", []) or []:
|
|
80
|
+
full = entry.get("Name") or ""
|
|
81
|
+
if full.startswith(self.PREFIX):
|
|
82
|
+
out.append(full[len(self.PREFIX) :])
|
|
83
|
+
return sorted(out)
|