Skip to content

Commit 71625e8

Browse files
committed
Add keep iam command with workspace-scoped policy generation
- IamPolicyGenerator builds IAM policy JSON from vault config, namespace, and envs - SSM: per-env resource ARNs scoped to namespace/scope/env - Secrets Manager: tag-based conditions with env scoping - IamCommand respects workspace filtering, --all flag bypasses it - Init flow now offers workspace setup then IAM after vault config - VaultAddCommand offers IAM generation after adding a vault
1 parent 3f2dc47 commit 71625e8

7 files changed

Lines changed: 601 additions & 0 deletions

File tree

src/Commands/Concerns/ConfiguresVaults.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use STS\Keep\Facades\Keep;
1010
use STS\Keep\Services\LocalStorage;
1111

12+
use function Laravel\Prompts\confirm;
1213
use function Laravel\Prompts\error;
1314
use function Laravel\Prompts\info;
1415
use function Laravel\Prompts\select;
@@ -107,6 +108,7 @@ protected function configureNewVault(): ?array
107108
info(' - Instance profile (on EC2/ECS/Lambda)');
108109
info('');
109110
info('Skipping permission verification. Run "keep verify" once credentials are configured.');
111+
info('Run "keep iam" to generate the IAM policy for this vault.');
110112

111113
return ['slug' => $slug, 'config' => $vaultConfig];
112114
}

src/Commands/IamCommand.php

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
namespace STS\Keep\Commands;
4+
5+
use STS\Keep\Exceptions\KeepException;
6+
use STS\Keep\Facades\Keep;
7+
use STS\Keep\Services\IamPolicyGenerator;
8+
use Symfony\Component\Process\Process;
9+
10+
class IamCommand extends BaseCommand
11+
{
12+
public $signature = 'iam
13+
{--vault= : Generate policy for a specific vault only}
14+
{--all : Generate policy for all vaults and environments, ignoring workspace}
15+
{--browser : Open the AWS IAM console to create a policy}';
16+
17+
public $description = 'Generate an IAM policy for your configured vaults';
18+
19+
protected function process()
20+
{
21+
$namespace = Keep::getNamespace();
22+
$useAll = $this->option('all');
23+
24+
$vaults = $useAll ? Keep::getAllConfiguredVaults() : Keep::getConfiguredVaults();
25+
$envs = $useAll ? Keep::getAllEnvs() : Keep::getEnvs();
26+
27+
if ($vaultFilter = $this->option('vault')) {
28+
$allVaults = Keep::getAllConfiguredVaults();
29+
if (! $allVaults->has($vaultFilter)) {
30+
throw new KeepException("Vault '{$vaultFilter}' is not configured.");
31+
}
32+
$vaults = $allVaults->only($vaultFilter);
33+
}
34+
35+
if ($vaults->isEmpty()) {
36+
throw (new KeepException('No vaults are configured.'))->withContext([
37+
'suggestion' => 'Add a vault first with: keep vault:add',
38+
]);
39+
}
40+
41+
$this->line('');
42+
$this->line('<info>IAM Policy for Keep</info>');
43+
44+
if (! $useAll && ! $this->option('vault')) {
45+
$this->line('<comment> Scoped to your workspace. Use --all for all vaults and environments.</comment>');
46+
}
47+
48+
$this->line('');
49+
$this->line(" Namespace: <comment>{$namespace}</comment>");
50+
$this->line(' Environments: <comment>' . implode(', ', $envs) . '</comment>');
51+
52+
foreach ($vaults as $vault) {
53+
$region = $vault->get('region', 'us-east-1');
54+
$this->line(" Vault: <comment>{$vault->slug()}</comment> ({$vault->driver()}, {$region})");
55+
}
56+
57+
$this->line('');
58+
59+
$generator = new IamPolicyGenerator();
60+
$policy = $generator->generate($vaults, $namespace, $envs);
61+
62+
$this->line(json_encode($policy, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
63+
64+
$this->line('');
65+
$this->line('Create this policy in the AWS IAM console and attach it to your user or role.');
66+
67+
if ($this->option('browser')) {
68+
$this->line('');
69+
$this->line('Opening AWS IAM console...');
70+
$this->openBrowser('https://console.aws.amazon.com/iam/home#/policies/create');
71+
}
72+
}
73+
74+
protected function openBrowser(string $url): void
75+
{
76+
$commands = [
77+
'Darwin' => 'open',
78+
'Linux' => 'xdg-open',
79+
'Windows' => 'start',
80+
];
81+
82+
$os = PHP_OS_FAMILY === 'Windows' ? 'Windows' : PHP_OS;
83+
84+
if (isset($commands[$os])) {
85+
$process = new Process([$commands[$os], $url]);
86+
$process->run();
87+
}
88+
}
89+
}

src/Commands/InitCommand.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ protected function process()
9292
$result = $this->configureNewVault();
9393

9494
if ($result) {
95+
$this->offerWorkspaceSetup();
96+
$this->offerIamPolicy();
97+
9598
note('🎉 All set! Your Keep configuration is ready to use.');
9699
note('Next step: Set your first secret with: keep set MY_SECRET');
97100
} else {
@@ -109,6 +112,38 @@ protected function process()
109112
}
110113
}
111114

115+
private function offerWorkspaceSetup(): void
116+
{
117+
if ($this->option('no-interaction')) {
118+
return;
119+
}
120+
121+
info('');
122+
info('🎯 Workspace Setup');
123+
note('A workspace lets you select which vaults and environments you personally work with.');
124+
125+
if (confirm('Would you like to configure your workspace now?', true)) {
126+
$this->call('workspace');
127+
} else {
128+
note('You can configure your workspace later with: keep workspace');
129+
}
130+
}
131+
132+
private function offerIamPolicy(): void
133+
{
134+
if ($this->option('no-interaction')) {
135+
return;
136+
}
137+
138+
info('');
139+
140+
if (confirm('Would you like to generate the IAM policy JSON for your setup?', false)) {
141+
$this->call('iam');
142+
} else {
143+
note('You can generate it anytime with: keep iam');
144+
}
145+
}
146+
112147
private function detectAppName(): string
113148
{
114149
$cwd = getcwd();

src/Commands/VaultAddCommand.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use STS\Keep\Commands\Concerns\ConfiguresVaults;
66

7+
use function Laravel\Prompts\confirm;
78
use function Laravel\Prompts\info;
89
use function Laravel\Prompts\note;
910

@@ -26,6 +27,10 @@ protected function process()
2627
return self::FAILURE;
2728
}
2829

30+
if (! $this->option('no-interaction') && confirm('Would you like to generate the IAM policy JSON for this vault?', false)) {
31+
$this->call('iam', ['--vault' => $result['slug']]);
32+
}
33+
2934
return self::SUCCESS;
3035
}
3136
}

src/KeepApplication.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ public function __construct(protected KeepInstall $install)
5757

5858
Commands\DiffCommand::class,
5959
Commands\VerifyCommand::class,
60+
Commands\IamCommand::class,
6061
Commands\TemplateValidateCommand::class,
6162
Commands\TemplateAddCommand::class,
6263

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<?php
2+
3+
namespace STS\Keep\Services;
4+
5+
use Illuminate\Support\Collection;
6+
use STS\Keep\Data\VaultConfig;
7+
8+
class IamPolicyGenerator
9+
{
10+
public function generate(Collection $vaults, string $namespace, array $envs = []): array
11+
{
12+
$statements = [];
13+
14+
$ssmVaults = $vaults->filter(fn (VaultConfig $v) => $v->driver() === 'ssm');
15+
$smVaults = $vaults->filter(fn (VaultConfig $v) => $v->driver() === 'secretsmanager');
16+
17+
if ($ssmVaults->isNotEmpty()) {
18+
$statements = array_merge($statements, $this->ssmStatements($ssmVaults, $namespace, $envs));
19+
}
20+
21+
if ($smVaults->isNotEmpty()) {
22+
$statements = array_merge($statements, $this->secretsManagerStatements($smVaults, $namespace, $envs));
23+
}
24+
25+
return [
26+
'Version' => '2012-10-17',
27+
'Statement' => $statements,
28+
];
29+
}
30+
31+
protected function ssmStatements(Collection $vaults, string $namespace, array $envs): array
32+
{
33+
$resources = [];
34+
$kmsResources = [];
35+
36+
foreach ($vaults as $vault) {
37+
$region = $vault->get('region', '*');
38+
$scope = trim($vault->scope(), '/');
39+
$base = $scope ? "{$namespace}/{$scope}" : $namespace;
40+
41+
if (empty($envs)) {
42+
$resources[] = "arn:aws:ssm:{$region}:*:parameter/{$base}/*";
43+
} else {
44+
foreach ($envs as $env) {
45+
$resources[] = "arn:aws:ssm:{$region}:*:parameter/{$base}/{$env}/*";
46+
}
47+
}
48+
49+
$customKey = $vault->get('key');
50+
$kmsResources[] = $customKey ?: "arn:aws:kms:{$region}:*:alias/aws/ssm";
51+
}
52+
53+
$resources = array_values(array_unique($resources));
54+
$kmsResources = array_values(array_unique($kmsResources));
55+
56+
return [
57+
[
58+
'Sid' => 'KeepSsmAccess',
59+
'Effect' => 'Allow',
60+
'Action' => [
61+
'ssm:GetParameter',
62+
'ssm:GetParameters',
63+
'ssm:GetParametersByPath',
64+
'ssm:GetParameterHistory',
65+
'ssm:PutParameter',
66+
'ssm:DeleteParameter',
67+
],
68+
'Resource' => count($resources) === 1 ? $resources[0] : $resources,
69+
],
70+
[
71+
'Sid' => 'KeepSsmKms',
72+
'Effect' => 'Allow',
73+
'Action' => [
74+
'kms:Decrypt',
75+
'kms:Encrypt',
76+
'kms:GenerateDataKey',
77+
],
78+
'Resource' => count($kmsResources) === 1 ? $kmsResources[0] : $kmsResources,
79+
],
80+
];
81+
}
82+
83+
protected function secretsManagerStatements(Collection $vaults, string $namespace, array $envs): array
84+
{
85+
$kmsResources = [];
86+
87+
foreach ($vaults as $vault) {
88+
$region = $vault->get('region', '*');
89+
$customKey = $vault->get('key');
90+
$kmsResources[] = $customKey ?: "arn:aws:kms:{$region}:*:alias/aws/secretsmanager";
91+
}
92+
93+
$kmsResources = array_values(array_unique($kmsResources));
94+
95+
$tagKeys = ['ManagedBy', 'Namespace', 'Env', 'VaultSlug'];
96+
97+
$hasScope = $vaults->contains(fn (VaultConfig $v) => trim($v->scope(), '/') !== '');
98+
if ($hasScope) {
99+
$tagKeys[] = 'Scope';
100+
}
101+
102+
$readWriteCondition = [
103+
'StringEquals' => [
104+
'secretsmanager:ResourceTag/Namespace' => $namespace,
105+
],
106+
];
107+
108+
$createCondition = [
109+
'StringEquals' => [
110+
'aws:RequestTag/Namespace' => $namespace,
111+
],
112+
'ForAllValues:StringEquals' => [
113+
'aws:TagKeys' => $tagKeys,
114+
],
115+
];
116+
117+
if (! empty($envs)) {
118+
$envValue = count($envs) === 1 ? $envs[0] : $envs;
119+
120+
$readWriteCondition['ForAnyValue:StringEquals'] = [
121+
'secretsmanager:ResourceTag/Env' => $envValue,
122+
];
123+
124+
$createCondition['ForAnyValue:StringEquals'] = [
125+
'aws:RequestTag/Env' => $envValue,
126+
];
127+
}
128+
129+
return [
130+
[
131+
'Sid' => 'KeepSecretsManagerReadWrite',
132+
'Effect' => 'Allow',
133+
'Action' => [
134+
'secretsmanager:GetSecretValue',
135+
'secretsmanager:DescribeSecret',
136+
'secretsmanager:ListSecretVersionIds',
137+
'secretsmanager:PutSecretValue',
138+
'secretsmanager:UpdateSecret*',
139+
'secretsmanager:DeleteSecret',
140+
'secretsmanager:RestoreSecret',
141+
'secretsmanager:TagResource',
142+
'secretsmanager:UntagResource',
143+
],
144+
'Resource' => '*',
145+
'Condition' => $readWriteCondition,
146+
],
147+
[
148+
'Sid' => 'KeepSecretsManagerList',
149+
'Effect' => 'Allow',
150+
'Action' => [
151+
'secretsmanager:ListSecrets',
152+
'secretsmanager:BatchGetSecretValue',
153+
],
154+
'Resource' => '*',
155+
],
156+
[
157+
'Sid' => 'KeepSecretsManagerCreate',
158+
'Effect' => 'Allow',
159+
'Action' => 'secretsmanager:CreateSecret',
160+
'Resource' => '*',
161+
'Condition' => $createCondition,
162+
],
163+
[
164+
'Sid' => 'KeepSecretsManagerKms',
165+
'Effect' => 'Allow',
166+
'Action' => [
167+
'kms:Decrypt',
168+
'kms:Encrypt',
169+
'kms:GenerateDataKey',
170+
],
171+
'Resource' => count($kmsResources) === 1 ? $kmsResources[0] : $kmsResources,
172+
],
173+
];
174+
}
175+
}

0 commit comments

Comments
 (0)