File Coverage

blib/lib/WWW/OpenBao.pm
Criterion Covered Total %
statement 19 67 28.3
branch 0 14 0.0
condition 0 12 0.0
subroutine 7 20 35.0
pod 10 10 100.0
total 36 123 29.2


line stmt bran cond sub pod time code
1             package WWW::OpenBao;
2             # ABSTRACT: HTTP client for OpenBao / HashiCorp Vault API
3             our $VERSION = '0.001';
4 2     2   458848 use Moo;
  2         17646  
  2         9  
5 2     2   3922 use HTTP::Tiny;
  2         100797  
  2         108  
6 2     2   1067 use JSON::MaybeXS;
  2         21333  
  2         128  
7 2     2   13 use Carp qw(croak);
  2         2  
  2         65  
8 2     2   833 use namespace::clean;
  2         41131  
  2         12  
9              
10             has endpoint => (is => 'ro', required => 1);
11             has token => (is => 'rw', default => sub { '' });
12             has kv_mount => (is => 'ro', default => sub { 'secret' });
13             has _http => (is => 'lazy');
14              
15 0     0   0 sub _build__http { HTTP::Tiny->new(timeout => 10) }
16              
17             # KV v2 paths
18 2     2   3406 sub _kv_path { my ($self, $p) = @_; "v1/" . $self->kv_mount . "/data/$p" }
  2         19  
19 1     1   5 sub _kv_metadata_path { my ($self, $p) = @_; "v1/" . $self->kv_mount . "/metadata/$p" }
  1         13  
20              
21             # Core HTTP
22             sub _request {
23 0     0     my ($self, $method, $path, $body) = @_;
24 0           my $url = $self->endpoint . '/' . $path;
25 0           my %opts = (headers => {
26             'X-Vault-Token' => $self->token,
27             });
28 0 0         if ($body) {
29 0           $opts{content} = encode_json($body);
30 0           $opts{headers}{'Content-Type'} = 'application/json';
31             }
32 0           my $resp = $self->_http->request($method, $url, \%opts);
33 0 0         return undef if $resp->{status} == 404;
34             croak "OpenBao $method $path: $resp->{status} $resp->{content}"
35 0 0         unless $resp->{success};
36 0 0         return $resp->{content} ? decode_json($resp->{content}) : {};
37             }
38              
39             # KV v2: read secret data
40             sub read_secret {
41 0     0 1   my ($self, $path) = @_;
42 0           my $resp = $self->_request('GET', $self->_kv_path($path));
43 0 0         return undef unless $resp;
44 0           return $resp->{data}{data};
45             }
46              
47             # KV v2: write secret data
48             sub write_secret {
49 0     0 1   my ($self, $path, $data) = @_;
50 0           return $self->_request('POST', $self->_kv_path($path), { data => $data });
51             }
52              
53             # KV v2: delete secret (all versions + metadata)
54             sub delete_secret {
55 0     0 1   my ($self, $path) = @_;
56 0           return $self->_request('DELETE', $self->_kv_metadata_path($path));
57             }
58              
59             # KV v2: list secrets at path
60             sub list_secrets {
61 0     0 1   my ($self, $path) = @_;
62 0           my $resp = $self->_request('LIST', $self->_kv_metadata_path($path));
63 0 0         return [] unless $resp;
64 0   0       return $resp->{data}{keys} // [];
65             }
66              
67             # KV v2: check if secret exists without fetching data
68             sub secret_exists {
69 0     0 1   my ($self, $path) = @_;
70 0           my $resp = eval { $self->_request('GET', $self->_kv_metadata_path($path)) };
  0            
71 0           return defined $resp;
72             }
73              
74             # Auth: Kubernetes ServiceAccount login
75             sub login_k8s {
76 0     0 1   my ($self, %args) = @_;
77 0   0       my $role = $args{role} // croak "login_k8s requires 'role'";
78 0   0       my $jwt = $args{jwt} // _read_sa_token();
79 0           my $resp = $self->_request('POST', 'v1/auth/kubernetes/login', {
80             role => $role, jwt => $jwt,
81             });
82 0           $self->token($resp->{auth}{client_token});
83 0           return $resp->{auth};
84             }
85              
86             sub _read_sa_token {
87 0     0     my $path = '/var/run/secrets/kubernetes.io/serviceaccount/token';
88 0 0         open my $fh, '<', $path or croak "Cannot read SA token: $!";
89 0           local $/;
90 0           return <$fh>;
91             }
92              
93             # Sys: health check
94             sub health {
95 0     0 1   my ($self) = @_;
96 0           return eval { $self->_request('GET', 'v1/sys/health') };
  0            
97             }
98              
99             # Sys: initialize vault (first time)
100             sub init {
101 0     0 1   my ($self, %args) = @_;
102 0   0       my $shares = $args{secret_shares} // 1;
103 0   0       my $threshold = $args{secret_threshold} // 1;
104 0           return $self->_request('POST', 'v1/sys/init', {
105             secret_shares => $shares, secret_threshold => $threshold,
106             });
107             }
108              
109             # Sys: unseal
110             sub unseal {
111 0     0 1   my ($self, $key) = @_;
112 0           return $self->_request('POST', 'v1/sys/unseal', { key => $key });
113             }
114              
115             # Sys: enable secrets engine
116             sub enable_engine {
117 0     0 1   my ($self, $path, $type) = @_;
118 0           return $self->_request('POST', "v1/sys/mounts/$path", { type => $type });
119             }
120              
121             1;
122              
123             __END__