mirror of
https://github.com/hashicorp/vault.git
synced 2026-06-04 06:15:02 -04:00
Merge branch 'master' into token-roles
This commit is contained in:
commit
8b6df2a1a4
45 changed files with 1600 additions and 53 deletions
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -1,6 +1,20 @@
|
|||
## 0.5.2 (Unreleased)
|
||||
|
||||
FEATURES:
|
||||
|
||||
* **Token Accessors**: Vault now provides an accessor with each issued token.
|
||||
This accessor is an identifier that can be used for a limited set of
|
||||
actions, notably for token revocation. This value is logged in plaintext to
|
||||
audit logs, and in combination with the plaintext metadata logged to audit
|
||||
logs, provides a searchable and straightforward way to revoke particular
|
||||
users' or services' tokens in many cases.
|
||||
|
||||
IMPROVEMENTS:
|
||||
* auth/token,sys/capabilities: Added new endpoints
|
||||
`auth/token/lookup-accessor`, `auth/token/revoke-accessor` and
|
||||
`sys/capabilities-accessor`, which enables performing the respective actions
|
||||
with just the accessor of the tokens, without having access to the actual
|
||||
token [GH-1188]
|
||||
* core: Ignore leading `/` in policy paths [GH-1170]
|
||||
* core: Ignore leading `/` in mount paths [GH-1172]
|
||||
* command/server: The initial root token ID when running in `-dev` mode can
|
||||
|
|
@ -19,7 +33,19 @@ IMPROVEMENTS:
|
|||
must be matched exactly (issuer and serial number) for authentication, and
|
||||
the certificate must carry the client authentication or 'any' extended usage
|
||||
attributes. [GH-1153]
|
||||
* secret/ssh: Added documentation for `ssh/config/zeroaddress` endpoint. [GH-1154]
|
||||
* credential/cert: Subject and Authority key IDs are output in metadata; this
|
||||
allows more flexible searching/revocation in the audit logs [GH-1183]
|
||||
* secret/pki: Add revocation time (zero or Unix epoch) to `pki/cert/SERIAL`
|
||||
endpoint [GH-1180]
|
||||
* secret/pki: Sanitize serial number in `pki/revoke` endpoint to allow some
|
||||
other formats [GH-1187]
|
||||
* secret/ssh: Added documentation for `ssh/config/zeroaddress` endpoint.
|
||||
[GH-1154]
|
||||
* sys: Added new endpoints `sys/capabilities` and `sys/capabilities-self` to
|
||||
fetch the capabilities of a token on a given path [GH-1171]
|
||||
* sys: Added `sys/revoke-force`, which enables a user to ignore backend errors
|
||||
when revoking a lease, necessary in some emergency/failure scenarios
|
||||
[GH-1168]
|
||||
|
||||
BUG FIXES:
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ type Secret struct {
|
|||
// SecretAuth is the structure containing auth information if we have it.
|
||||
type SecretAuth struct {
|
||||
ClientToken string `json:"client_token"`
|
||||
Accessor string `json:"accessor"`
|
||||
Policies []string `json:"policies"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
|
||||
|
|
|
|||
48
api/sys_capabilities.go
Normal file
48
api/sys_capabilities.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package api
|
||||
|
||||
func (c *Sys) CapabilitiesSelf(path string) ([]string, error) {
|
||||
body := map[string]string{
|
||||
"path": path,
|
||||
}
|
||||
|
||||
r := c.c.NewRequest("POST", "/v1/sys/capabilities-self")
|
||||
if err := r.SetJSONBody(body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.c.RawRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result CapabilitiesResponse
|
||||
err = resp.DecodeJSON(&result)
|
||||
return result.Capabilities, err
|
||||
}
|
||||
|
||||
func (c *Sys) Capabilities(token, path string) ([]string, error) {
|
||||
body := map[string]string{
|
||||
"token": token,
|
||||
"path": path,
|
||||
}
|
||||
|
||||
r := c.c.NewRequest("POST", "/v1/sys/capabilities")
|
||||
if err := r.SetJSONBody(body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.c.RawRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result CapabilitiesResponse
|
||||
err = resp.DecodeJSON(&result)
|
||||
return result.Capabilities, err
|
||||
}
|
||||
|
||||
type CapabilitiesResponse struct {
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
|
@ -34,3 +34,12 @@ func (c *Sys) RevokePrefix(id string) error {
|
|||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Sys) RevokeForce(id string) error {
|
||||
r := c.c.NewRequest("PUT", "/v1/sys/revoke-force/"+id)
|
||||
resp, err := c.c.RawRequest(r)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ func (f *FormatJSON) FormatResponse(
|
|||
if resp.Auth != nil {
|
||||
respAuth = &JSONAuth{
|
||||
ClientToken: resp.Auth.ClientToken,
|
||||
Accessor: resp.Auth.Accessor,
|
||||
DisplayName: resp.Auth.DisplayName,
|
||||
Policies: resp.Auth.Policies,
|
||||
Metadata: resp.Auth.Metadata,
|
||||
|
|
@ -149,6 +150,7 @@ type JSONResponse struct {
|
|||
|
||||
type JSONAuth struct {
|
||||
ClientToken string `json:"client_token,omitempty"`
|
||||
Accessor string `json:"accessor,omitempty"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Policies []string `json:"policies"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/vault/helper/certutil"
|
||||
"github.com/hashicorp/vault/logical"
|
||||
"github.com/hashicorp/vault/logical/framework"
|
||||
)
|
||||
|
|
@ -67,8 +68,10 @@ func (b *backend) pathLogin(
|
|||
Policies: matched.Entry.Policies,
|
||||
DisplayName: matched.Entry.DisplayName,
|
||||
Metadata: map[string]string{
|
||||
"cert_name": matched.Entry.Name,
|
||||
"common_name": clientCerts[0].Subject.CommonName,
|
||||
"cert_name": matched.Entry.Name,
|
||||
"common_name": clientCerts[0].Subject.CommonName,
|
||||
"subject_key_id": certutil.GetOctalFormatted(clientCerts[0].SubjectKeyId, ":"),
|
||||
"authority_key_id": certutil.GetOctalFormatted(clientCerts[0].AuthorityKeyId, ":"),
|
||||
},
|
||||
LeaseOptions: logical.LeaseOptions{
|
||||
Renewable: true,
|
||||
|
|
|
|||
|
|
@ -29,19 +29,19 @@ func TestBackend_Config(t *testing.T) {
|
|||
"token": os.Getenv("GITHUB_TOKEN"),
|
||||
}
|
||||
config_data1 := map[string]interface{}{
|
||||
"organization": "hashicorp",
|
||||
"organization": os.Getenv("GITHUB_ORG"),
|
||||
"ttl": "",
|
||||
"max_ttl": "",
|
||||
}
|
||||
expectedTTL1, _ := time.ParseDuration("24h0m0s")
|
||||
config_data2 := map[string]interface{}{
|
||||
"organization": "hashicorp",
|
||||
"organization": os.Getenv("GITHUB_ORG"),
|
||||
"ttl": "1h",
|
||||
"max_ttl": "2h",
|
||||
}
|
||||
expectedTTL2, _ := time.ParseDuration("1h0m0s")
|
||||
config_data3 := map[string]interface{}{
|
||||
"organization": "hashicorp",
|
||||
"organization": os.Getenv("GITHUB_ORG"),
|
||||
"ttl": "50h",
|
||||
"max_ttl": "50h",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ func TestBackend_role_lease(t *testing.T) {
|
|||
|
||||
func testStartConsulServer(t *testing.T) (map[string]interface{}, *os.Process) {
|
||||
if _, err := exec.LookPath("consul"); err != nil {
|
||||
t.Skipf("consul not found: %s", err)
|
||||
t.Errorf("consul not found: %s", err)
|
||||
}
|
||||
|
||||
td, err := ioutil.TempDir("", "vault")
|
||||
|
|
|
|||
|
|
@ -914,6 +914,26 @@ func generateCATestingSteps(t *testing.T, caCert, caKey, otherCaCert string, int
|
|||
Data: reqdata,
|
||||
Check: func(resp *logical.Response) error {
|
||||
delete(reqdata, "certificate")
|
||||
|
||||
serialUnderTest = "cert/" + reqdata["rsa_int_serial_number"].(string)
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
||||
// We expect to find a zero revocation time
|
||||
logicaltest.TestStep{
|
||||
Operation: logical.ReadOperation,
|
||||
PreFlight: setSerialUnderTest,
|
||||
Check: func(resp *logical.Response) error {
|
||||
if resp.Data["error"] != nil && resp.Data["error"].(string) != "" {
|
||||
return fmt.Errorf("got an error: %s", resp.Data["error"].(string))
|
||||
}
|
||||
|
||||
if resp.Data["revocation_time"].(int64) != 0 {
|
||||
return fmt.Errorf("expected a zero revocation time")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
|
@ -1051,10 +1071,29 @@ func generateCATestingSteps(t *testing.T, caCert, caKey, otherCaCert string, int
|
|||
Data: reqdata,
|
||||
Check: func(resp *logical.Response) error {
|
||||
delete(reqdata, "certificate")
|
||||
|
||||
serialUnderTest = "cert/" + reqdata["ec_int_serial_number"].(string)
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
||||
// We expect to find a zero revocation time
|
||||
logicaltest.TestStep{
|
||||
Operation: logical.ReadOperation,
|
||||
PreFlight: setSerialUnderTest,
|
||||
Check: func(resp *logical.Response) error {
|
||||
if resp.Data["error"] != nil && resp.Data["error"].(string) != "" {
|
||||
return fmt.Errorf("got an error: %s", resp.Data["error"].(string))
|
||||
}
|
||||
|
||||
if resp.Data["revocation_time"].(int64) != 0 {
|
||||
return fmt.Errorf("expected a zero revocation time")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
logicaltest.TestStep{
|
||||
Operation: logical.UpdateOperation,
|
||||
Path: "revoke",
|
||||
|
|
@ -1102,6 +1141,10 @@ func generateCATestingSteps(t *testing.T, caCert, caKey, otherCaCert string, int
|
|||
return fmt.Errorf("got an error: %s", resp.Data["error"].(string))
|
||||
}
|
||||
|
||||
if resp.Data["revocation_time"].(int64) == 0 {
|
||||
return fmt.Errorf("expected a non-zero revocation time")
|
||||
}
|
||||
|
||||
serialUnderTest = "cert/" + reqdata["ec_int_serial_number"].(string)
|
||||
|
||||
return nil
|
||||
|
|
@ -1116,6 +1159,10 @@ func generateCATestingSteps(t *testing.T, caCert, caKey, otherCaCert string, int
|
|||
return fmt.Errorf("got an error: %s", resp.Data["error"].(string))
|
||||
}
|
||||
|
||||
if resp.Data["revocation_time"].(int64) == 0 {
|
||||
return fmt.Errorf("expected a non-zero revocation time")
|
||||
}
|
||||
|
||||
// Give time for the certificates to pass the safety buffer
|
||||
t.Logf("Sleeping for 15 seconds to allow safety buffer time to pass before testing tidying")
|
||||
time.Sleep(15 * time.Second)
|
||||
|
|
|
|||
|
|
@ -162,12 +162,14 @@ func fetchCertBySerial(req *logical.Request, prefix, serial string) (*logical.St
|
|||
var path string
|
||||
|
||||
switch {
|
||||
// Revoked goes first as otherwise ca/crl get hardcoded paths which fail if
|
||||
// we actually want revocation info
|
||||
case strings.HasPrefix(prefix, "revoked/"):
|
||||
path = "revoked/" + strings.Replace(strings.ToLower(serial), "-", ":", -1)
|
||||
case serial == "ca":
|
||||
path = "ca"
|
||||
case serial == "crl":
|
||||
path = "crl"
|
||||
case strings.HasPrefix(prefix, "revoked/"):
|
||||
path = "revoked/" + strings.Replace(strings.ToLower(serial), "-", ":", -1)
|
||||
default:
|
||||
path = "certs/" + strings.Replace(strings.ToLower(serial), "-", ":", -1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,12 +74,11 @@ func pathFetchCRLViaCertPath(b *backend) *framework.Path {
|
|||
}
|
||||
|
||||
func (b *backend) pathFetchRead(req *logical.Request, data *framework.FieldData) (response *logical.Response, retErr error) {
|
||||
var serial string
|
||||
var pemType string
|
||||
var contentType string
|
||||
var certEntry *logical.StorageEntry
|
||||
var serial, pemType, contentType string
|
||||
var certEntry, revokedEntry *logical.StorageEntry
|
||||
var funcErr error
|
||||
var certificate []byte
|
||||
var revocationTime int64
|
||||
response = &logical.Response{
|
||||
Data: map[string]interface{}{},
|
||||
}
|
||||
|
|
@ -140,6 +139,26 @@ func (b *backend) pathFetchRead(req *logical.Request, data *framework.FieldData)
|
|||
certificate = pem.EncodeToMemory(&block)
|
||||
}
|
||||
|
||||
revokedEntry, funcErr = fetchCertBySerial(req, "revoked/", serial)
|
||||
if funcErr != nil {
|
||||
switch funcErr.(type) {
|
||||
case certutil.UserError:
|
||||
response = logical.ErrorResponse(funcErr.Error())
|
||||
goto reply
|
||||
case certutil.InternalError:
|
||||
retErr = funcErr
|
||||
goto reply
|
||||
}
|
||||
}
|
||||
if revokedEntry != nil {
|
||||
var revInfo revocationInfo
|
||||
err := revokedEntry.DecodeJSON(&revInfo)
|
||||
if err != nil {
|
||||
return logical.ErrorResponse(fmt.Sprintf("Error decoding revocation entry for serial %s: %s", serial, err)), nil
|
||||
}
|
||||
revocationTime = revInfo.RevocationTime
|
||||
}
|
||||
|
||||
reply:
|
||||
switch {
|
||||
case len(contentType) != 0:
|
||||
|
|
@ -157,6 +176,7 @@ reply:
|
|||
response = nil
|
||||
default:
|
||||
response.Data["certificate"] = string(certificate)
|
||||
response.Data["revocation_time"] = revocationTime
|
||||
}
|
||||
|
||||
return
|
||||
|
|
|
|||
|
|
@ -171,8 +171,9 @@ func (b *backend) pathIssueSignCert(
|
|||
|
||||
resp := b.Secret(SecretCertsType).Response(
|
||||
map[string]interface{}{
|
||||
"certificate": cb.Certificate,
|
||||
"issuing_ca": cb.IssuingCA,
|
||||
"certificate": cb.Certificate,
|
||||
"issuing_ca": cb.IssuingCA,
|
||||
"serial_number": cb.SerialNumber,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"serial_number": cb.SerialNumber,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package pki
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/vault/helper/certutil"
|
||||
"github.com/hashicorp/vault/logical"
|
||||
|
|
@ -47,6 +48,10 @@ func (b *backend) pathRevokeWrite(req *logical.Request, data *framework.FieldDat
|
|||
return logical.ErrorResponse("The serial number must be provided"), nil
|
||||
}
|
||||
|
||||
// We store and identify by lowercase colon-separated hex, but other
|
||||
// utilities use dashes and/or uppercase, so normalize
|
||||
serial = strings.Replace(strings.ToLower(serial), "-", ":", -1)
|
||||
|
||||
b.revokeStorageLock.Lock()
|
||||
defer b.revokeStorageLock.Unlock()
|
||||
|
||||
|
|
|
|||
|
|
@ -290,6 +290,12 @@ func Commands(metaPtr *command.Meta) map[string]cli.CommandFactory {
|
|||
}, nil
|
||||
},
|
||||
|
||||
"capabilities": func() (cli.Command, error) {
|
||||
return &command.CapabilitiesCommand{
|
||||
Meta: meta,
|
||||
}, nil
|
||||
},
|
||||
|
||||
"version": func() (cli.Command, error) {
|
||||
versionInfo := version.GetVersion()
|
||||
|
||||
|
|
|
|||
86
command/capabilities.go
Normal file
86
command/capabilities.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CapabilitiesCommand is a Command that enables a new endpoint.
|
||||
type CapabilitiesCommand struct {
|
||||
Meta
|
||||
}
|
||||
|
||||
func (c *CapabilitiesCommand) Run(args []string) int {
|
||||
flags := c.Meta.FlagSet("capabilities", FlagSetDefault)
|
||||
flags.Usage = func() { c.Ui.Error(c.Help()) }
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 1
|
||||
}
|
||||
|
||||
args = flags.Args()
|
||||
if len(args) > 2 {
|
||||
flags.Usage()
|
||||
c.Ui.Error(fmt.Sprintf(
|
||||
"\ncapabilities expects at most two arguments"))
|
||||
return 1
|
||||
}
|
||||
|
||||
var token string
|
||||
var path string
|
||||
switch {
|
||||
case len(args) == 1:
|
||||
path = args[0]
|
||||
case len(args) == 2:
|
||||
token = args[0]
|
||||
path = args[1]
|
||||
default:
|
||||
flags.Usage()
|
||||
c.Ui.Error(fmt.Sprintf("\ncapabilities expects at least one argument"))
|
||||
return 1
|
||||
}
|
||||
|
||||
client, err := c.Client()
|
||||
if err != nil {
|
||||
c.Ui.Error(fmt.Sprintf(
|
||||
"Error initializing client: %s", err))
|
||||
return 2
|
||||
}
|
||||
|
||||
var capabilities []string
|
||||
if token == "" {
|
||||
capabilities, err = client.Sys().CapabilitiesSelf(path)
|
||||
} else {
|
||||
capabilities, err = client.Sys().Capabilities(token, path)
|
||||
}
|
||||
if err != nil {
|
||||
c.Ui.Error(fmt.Sprintf(
|
||||
"Error retrieving capabilities: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
c.Ui.Output(fmt.Sprintf("Capabilities: %s", capabilities))
|
||||
return 0
|
||||
}
|
||||
|
||||
func (c *CapabilitiesCommand) Synopsis() string {
|
||||
return "Fetch the capabilities of a token on a given path"
|
||||
}
|
||||
|
||||
func (c *CapabilitiesCommand) Help() string {
|
||||
helpText := `
|
||||
Usage: vault capabilities [options] [token] path
|
||||
|
||||
Fetch the capabilities of a token on a given path.
|
||||
If a token is provided as an argument, the '/sys/capabilities' endpoint will be invoked
|
||||
with the given token; otherwise the '/sys/capabilities-self' endpoint will be invoked
|
||||
with the client token.
|
||||
|
||||
If a token does not have any capability on a given path, or if any of the policies
|
||||
belonging to the token explicitly have ["deny"] capability, or if the argument path
|
||||
is invalid, this command will respond with a ["deny"].
|
||||
|
||||
General Options:
|
||||
|
||||
` + generalOptionsUsage()
|
||||
return strings.TrimSpace(helpText)
|
||||
}
|
||||
44
command/capabilities_test.go
Normal file
44
command/capabilities_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/vault/http"
|
||||
"github.com/hashicorp/vault/vault"
|
||||
"github.com/mitchellh/cli"
|
||||
)
|
||||
|
||||
func TestCapabilities_Basic(t *testing.T) {
|
||||
core, _, token := vault.TestCoreUnsealed(t)
|
||||
ln, addr := http.TestServer(t, core)
|
||||
defer ln.Close()
|
||||
ui := new(cli.MockUi)
|
||||
c := &CapabilitiesCommand{
|
||||
Meta: Meta{
|
||||
ClientToken: token,
|
||||
Ui: ui,
|
||||
},
|
||||
}
|
||||
|
||||
var args []string
|
||||
|
||||
args = []string{"-address", addr}
|
||||
if code := c.Run(args); code == 0 {
|
||||
t.Fatalf("expected failure due to no args")
|
||||
}
|
||||
|
||||
args = []string{"-address", addr, "testpath"}
|
||||
if code := c.Run(args); code != 0 {
|
||||
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String())
|
||||
}
|
||||
|
||||
args = []string{"-address", addr, token, "test"}
|
||||
if code := c.Run(args); code != 0 {
|
||||
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String())
|
||||
}
|
||||
|
||||
args = []string{"-address", addr, "invalidtoken", "test"}
|
||||
if code := c.Run(args); code == 0 {
|
||||
t.Fatalf("expected failure due to invalid token")
|
||||
}
|
||||
}
|
||||
|
|
@ -143,6 +143,7 @@ func (t TableFormatter) OutputSecret(ui cli.Ui, secret, s *api.Secret) error {
|
|||
|
||||
if s.Auth != nil {
|
||||
input = append(input, fmt.Sprintf("token %s %s", config.Delim, s.Auth.ClientToken))
|
||||
input = append(input, fmt.Sprintf("token_accessor %s %s", config.Delim, s.Auth.Accessor))
|
||||
input = append(input, fmt.Sprintf("token_duration %s %d", config.Delim, s.Auth.LeaseDuration))
|
||||
input = append(input, fmt.Sprintf("token_renewable %s %v", config.Delim, s.Auth.Renewable))
|
||||
input = append(input, fmt.Sprintf("token_policies %s %v", config.Delim, s.Auth.Policies))
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ type RevokeCommand struct {
|
|||
}
|
||||
|
||||
func (c *RevokeCommand) Run(args []string) int {
|
||||
var prefix bool
|
||||
var prefix, force bool
|
||||
flags := c.Meta.FlagSet("revoke", FlagSetDefault)
|
||||
flags.BoolVar(&prefix, "prefix", false, "")
|
||||
flags.BoolVar(&force, "force", false, "")
|
||||
flags.Usage = func() { c.Ui.Error(c.Help()) }
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 1
|
||||
|
|
@ -35,9 +36,16 @@ func (c *RevokeCommand) Run(args []string) int {
|
|||
return 2
|
||||
}
|
||||
|
||||
if prefix {
|
||||
switch {
|
||||
case force && !prefix:
|
||||
c.Ui.Error(fmt.Sprintf(
|
||||
"-force requires -prefix"))
|
||||
return 1
|
||||
case force && prefix:
|
||||
err = client.Sys().RevokeForce(leaseId)
|
||||
case prefix:
|
||||
err = client.Sys().RevokePrefix(leaseId)
|
||||
} else {
|
||||
default:
|
||||
err = client.Sys().Revoke(leaseId)
|
||||
}
|
||||
if err != nil {
|
||||
|
|
@ -60,12 +68,16 @@ Usage: vault revoke [options] id
|
|||
|
||||
Revoke a secret by its lease ID.
|
||||
|
||||
This command revokes a secret by its lease ID that was returned
|
||||
with it. Once the key is revoked, it is no longer valid.
|
||||
This command revokes a secret by its lease ID that was returned with it. Once
|
||||
the key is revoked, it is no longer valid.
|
||||
|
||||
With the -prefix flag, the revoke is done by prefix: any secret prefixed
|
||||
with the given partial ID is revoked. Lease IDs are structured in such
|
||||
a way to make revocation of prefixes useful.
|
||||
With the -prefix flag, the revoke is done by prefix: any secret prefixed with
|
||||
the given partial ID is revoked. Lease IDs are structured in such a way to
|
||||
make revocation of prefixes useful.
|
||||
|
||||
With the -force flag, the lease is removed from Vault even if the revocation
|
||||
fails. This is meant for certain recovery scenarios and should not be used
|
||||
lightly. This option requires -prefix.
|
||||
|
||||
General Options:
|
||||
|
||||
|
|
@ -76,6 +88,8 @@ Revoke Options:
|
|||
-prefix=true Revoke all secrets with the matching prefix. This
|
||||
defaults to false: an exact revocation.
|
||||
|
||||
-force=true Delete the lease even if the actual revocation
|
||||
operation fails.
|
||||
`
|
||||
return strings.TrimSpace(helpText)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/errwrap"
|
||||
"github.com/hashicorp/vault/logical"
|
||||
"github.com/hashicorp/vault/vault"
|
||||
)
|
||||
|
|
@ -32,6 +33,9 @@ func Handler(core *vault.Core) http.Handler {
|
|||
mux.Handle("/v1/sys/generate-root/update", handleSysGenerateRootUpdate(core))
|
||||
mux.Handle("/v1/sys/rekey/init", handleSysRekeyInit(core))
|
||||
mux.Handle("/v1/sys/rekey/update", handleSysRekeyUpdate(core))
|
||||
mux.Handle("/v1/sys/capabilities", handleSysCapabilities(core))
|
||||
mux.Handle("/v1/sys/capabilities-self", handleSysCapabilities(core))
|
||||
mux.Handle("/v1/sys/capabilities-accessor", handleSysCapabilitiesAccessor(core))
|
||||
mux.Handle("/v1/sys/", handleLogical(core, true))
|
||||
mux.Handle("/v1/", handleLogical(core, false))
|
||||
|
||||
|
|
@ -77,7 +81,7 @@ func request(core *vault.Core, w http.ResponseWriter, rawReq *http.Request, r *l
|
|||
return resp, false
|
||||
}
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err)
|
||||
respondErrorStatus(w, err)
|
||||
return resp, false
|
||||
}
|
||||
|
||||
|
|
@ -137,6 +141,18 @@ func requestAuth(r *http.Request, req *logical.Request) *logical.Request {
|
|||
return req
|
||||
}
|
||||
|
||||
// Determines the type of the error being returned and sets the HTTP
|
||||
// status code appropriately
|
||||
func respondErrorStatus(w http.ResponseWriter, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
// Keep adding more error types here to appropriate the status codes
|
||||
case errwrap.ContainsType(err, new(vault.StatusBadRequest)):
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
respondError(w, status, err)
|
||||
}
|
||||
|
||||
func respondError(w http.ResponseWriter, status int, err error) {
|
||||
// Adjust status code when sealed
|
||||
if err == vault.ErrSealed {
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ func respondLogical(w http.ResponseWriter, r *http.Request, path string, dataOnl
|
|||
if resp.Auth != nil {
|
||||
logicalResp.Auth = &Auth{
|
||||
ClientToken: resp.Auth.ClientToken,
|
||||
Accessor: resp.Auth.Accessor,
|
||||
Policies: resp.Auth.Policies,
|
||||
Metadata: resp.Auth.Metadata,
|
||||
LeaseDuration: int(resp.Auth.TTL.Seconds()),
|
||||
|
|
@ -218,6 +219,7 @@ type LogicalResponse struct {
|
|||
|
||||
type Auth struct {
|
||||
ClientToken string `json:"client_token"`
|
||||
Accessor string `json:"accessor"`
|
||||
Policies []string `json:"policies"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
LeaseDuration int `json:"lease_duration"`
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ func TestLogical_StandbyRedirect(t *testing.T) {
|
|||
testResponseBody(t, resp, &actual)
|
||||
actualDataMap := actual["data"].(map[string]interface{})
|
||||
delete(actualDataMap, "creation_time")
|
||||
delete(actualDataMap, "accessor")
|
||||
actual["data"] = actualDataMap
|
||||
delete(actual, "lease_id")
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
|
|
@ -181,6 +182,7 @@ func TestLogical_CreateToken(t *testing.T) {
|
|||
testResponseStatus(t, resp, 200)
|
||||
testResponseBody(t, resp, &actual)
|
||||
delete(actual["auth"].(map[string]interface{}), "client_token")
|
||||
delete(actual["auth"].(map[string]interface{}), "accessor")
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Fatalf("bad:\nexpected:\n%#v\nactual:\n%#v", expected, actual)
|
||||
}
|
||||
|
|
|
|||
89
http/sys_capabilities.go
Normal file
89
http/sys_capabilities.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/vault/logical"
|
||||
"github.com/hashicorp/vault/vault"
|
||||
)
|
||||
|
||||
func handleSysCapabilitiesAccessor(core *vault.Core) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "PUT":
|
||||
case "POST":
|
||||
default:
|
||||
respondError(w, http.StatusMethodNotAllowed, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the request if we can
|
||||
var data capabilitiesAccessorRequest
|
||||
if err := parseRequest(r, &data); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
capabilities, err := core.CapabilitiesAccessor(data.Accessor, data.Path)
|
||||
if err != nil {
|
||||
respondErrorStatus(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondOk(w, &capabilitiesResponse{
|
||||
Capabilities: capabilities,
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func handleSysCapabilities(core *vault.Core) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "PUT":
|
||||
case "POST":
|
||||
default:
|
||||
respondError(w, http.StatusMethodNotAllowed, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the request if we can
|
||||
var data capabilitiesRequest
|
||||
if err := parseRequest(r, &data); err != nil {
|
||||
respondError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(r.URL.Path, "/v1/sys/capabilities-self") {
|
||||
// Get the auth for the request so we can access the token directly
|
||||
req := requestAuth(r, &logical.Request{})
|
||||
data.Token = req.ClientToken
|
||||
}
|
||||
|
||||
capabilities, err := core.Capabilities(data.Token, data.Path)
|
||||
if err != nil {
|
||||
respondErrorStatus(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondOk(w, &capabilitiesResponse{
|
||||
Capabilities: capabilities,
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
type capabilitiesResponse struct {
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
type capabilitiesRequest struct {
|
||||
Token string `json:"token"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type capabilitiesAccessorRequest struct {
|
||||
Accessor string `json:"accessor"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
154
http/sys_capabilities_test.go
Normal file
154
http/sys_capabilities_test.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/vault/vault"
|
||||
)
|
||||
|
||||
func TestSysCapabilitiesAccessor(t *testing.T) {
|
||||
core, _, token := vault.TestCoreUnsealed(t)
|
||||
ln, addr := TestServer(t, core)
|
||||
defer ln.Close()
|
||||
TestServerAuth(t, addr, token)
|
||||
|
||||
// Lookup the token properties
|
||||
resp := testHttpGet(t, token, addr+"/v1/auth/token/lookup/"+token)
|
||||
var lookupResp map[string]interface{}
|
||||
testResponseStatus(t, resp, 200)
|
||||
testResponseBody(t, resp, &lookupResp)
|
||||
|
||||
// Retrieve the accessor from the token properties
|
||||
lookupData := lookupResp["data"].(map[string]interface{})
|
||||
accessor := lookupData["accessor"].(string)
|
||||
|
||||
resp = testHttpPost(t, token, addr+"/v1/sys/capabilities-accessor", map[string]interface{}{
|
||||
"accessor": accessor,
|
||||
"path": "testpath",
|
||||
})
|
||||
|
||||
var actual map[string][]string
|
||||
testResponseStatus(t, resp, 200)
|
||||
testResponseBody(t, resp, &actual)
|
||||
|
||||
expected := map[string][]string{
|
||||
"capabilities": []string{"root"},
|
||||
}
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actual, expected)
|
||||
}
|
||||
|
||||
// Testing for non-root token's accessor
|
||||
// Create a policy first
|
||||
resp = testHttpPost(t, token, addr+"/v1/sys/policy/foo", map[string]interface{}{
|
||||
"rules": `path "testpath" {capabilities = ["read","sudo"]}`,
|
||||
})
|
||||
testResponseStatus(t, resp, 204)
|
||||
|
||||
// Create a token against the test policy
|
||||
resp = testHttpPost(t, token, addr+"/v1/auth/token/create", map[string]interface{}{
|
||||
"policies": []string{"foo"},
|
||||
})
|
||||
|
||||
var tokenResp map[string]interface{}
|
||||
testResponseStatus(t, resp, 200)
|
||||
testResponseBody(t, resp, &tokenResp)
|
||||
|
||||
// Check if desired policies are present in the token
|
||||
auth := tokenResp["auth"].(map[string]interface{})
|
||||
actualPolicies := auth["policies"]
|
||||
expectedPolicies := []interface{}{"default", "foo"}
|
||||
if !reflect.DeepEqual(actualPolicies, expectedPolicies) {
|
||||
t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actualPolicies, expectedPolicies)
|
||||
}
|
||||
|
||||
// Check the capabilities of non-root token using the accessor
|
||||
resp = testHttpPost(t, token, addr+"/v1/sys/capabilities-accessor", map[string]interface{}{
|
||||
"accessor": auth["accessor"],
|
||||
"path": "testpath",
|
||||
})
|
||||
testResponseStatus(t, resp, 200)
|
||||
testResponseBody(t, resp, &actual)
|
||||
|
||||
expected = map[string][]string{
|
||||
"capabilities": []string{"sudo", "read"},
|
||||
}
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actual, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysCapabilities(t *testing.T) {
|
||||
core, _, token := vault.TestCoreUnsealed(t)
|
||||
ln, addr := TestServer(t, core)
|
||||
defer ln.Close()
|
||||
TestServerAuth(t, addr, token)
|
||||
|
||||
// Send both token and path
|
||||
resp := testHttpPost(t, token, addr+"/v1/sys/capabilities", map[string]interface{}{
|
||||
"token": token,
|
||||
"path": "testpath",
|
||||
})
|
||||
|
||||
var actual map[string][]string
|
||||
testResponseStatus(t, resp, 200)
|
||||
testResponseBody(t, resp, &actual)
|
||||
|
||||
expected := map[string][]string{
|
||||
"capabilities": []string{"root"},
|
||||
}
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actual, expected)
|
||||
}
|
||||
|
||||
// Send only path to capabilities-self
|
||||
resp = testHttpPost(t, token, addr+"/v1/sys/capabilities-self", map[string]interface{}{
|
||||
"path": "testpath",
|
||||
})
|
||||
testResponseStatus(t, resp, 200)
|
||||
testResponseBody(t, resp, &actual)
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actual, expected)
|
||||
}
|
||||
|
||||
// Testing for non-root tokens
|
||||
|
||||
// Create a policy first
|
||||
resp = testHttpPost(t, token, addr+"/v1/sys/policy/foo", map[string]interface{}{
|
||||
"rules": `path "testpath" {capabilities = ["read","sudo"]}`,
|
||||
})
|
||||
testResponseStatus(t, resp, 204)
|
||||
|
||||
// Create a token against the test policy
|
||||
resp = testHttpPost(t, token, addr+"/v1/auth/token/create", map[string]interface{}{
|
||||
"policies": []string{"foo"},
|
||||
})
|
||||
|
||||
var tokenResp map[string]interface{}
|
||||
testResponseStatus(t, resp, 200)
|
||||
testResponseBody(t, resp, &tokenResp)
|
||||
|
||||
// Check if desired policies are present in the token
|
||||
auth := tokenResp["auth"].(map[string]interface{})
|
||||
actualPolicies := auth["policies"]
|
||||
expectedPolicies := []interface{}{"default", "foo"}
|
||||
if !reflect.DeepEqual(actualPolicies, expectedPolicies) {
|
||||
t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actualPolicies, expectedPolicies)
|
||||
}
|
||||
|
||||
// Check the capabilities with the created non-root token
|
||||
resp = testHttpPost(t, token, addr+"/v1/sys/capabilities", map[string]interface{}{
|
||||
"token": auth["client_token"],
|
||||
"path": "testpath",
|
||||
})
|
||||
testResponseStatus(t, resp, 200)
|
||||
testResponseBody(t, resp, &actual)
|
||||
|
||||
expected = map[string][]string{
|
||||
"capabilities": []string{"sudo", "read"},
|
||||
}
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actual, expected)
|
||||
}
|
||||
}
|
||||
|
|
@ -310,6 +310,7 @@ func TestSysGenerateRoot_Update_OTP(t *testing.T) {
|
|||
testResponseBody(t, resp, &actual)
|
||||
|
||||
expected["creation_time"] = actual["data"].(map[string]interface{})["creation_time"]
|
||||
expected["accessor"] = actual["data"].(map[string]interface{})["accessor"]
|
||||
|
||||
if !reflect.DeepEqual(actual["data"], expected) {
|
||||
t.Fatalf("\nexpected: %#v\nactual: %#v", expected, actual["data"])
|
||||
|
|
@ -391,6 +392,7 @@ func TestSysGenerateRoot_Update_PGP(t *testing.T) {
|
|||
testResponseBody(t, resp, &actual)
|
||||
|
||||
expected["creation_time"] = actual["data"].(map[string]interface{})["creation_time"]
|
||||
expected["accessor"] = actual["data"].(map[string]interface{})["accessor"]
|
||||
|
||||
if !reflect.DeepEqual(actual["data"], expected) {
|
||||
t.Fatalf("\nexpected: %#v\nactual: %#v", expected, actual["data"])
|
||||
|
|
|
|||
|
|
@ -33,6 +33,13 @@ type Auth struct {
|
|||
// This will be filled in by Vault core when an auth structure is
|
||||
// returned. Setting this manually will have no effect.
|
||||
ClientToken string
|
||||
|
||||
// Accessor is the identifier for the ClientToken. This can be used
|
||||
// to perform management functionalities (especially revocation) when
|
||||
// ClientToken in the audit logs are obfuscated. Accessor can be used
|
||||
// to revoke a ClientToken and to lookup the capabilities of the ClientToken,
|
||||
// both without actually knowing the ClientToken.
|
||||
Accessor string
|
||||
}
|
||||
|
||||
func (a *Auth) GoString() string {
|
||||
|
|
|
|||
50
vault/acl.go
50
vault/acl.go
|
|
@ -71,6 +71,56 @@ func NewACL(policies []*Policy) (*ACL, error) {
|
|||
return a, nil
|
||||
}
|
||||
|
||||
func (a *ACL) Capabilities(path string) (pathCapabilities []string) {
|
||||
// Fast-path root
|
||||
if a.root {
|
||||
return []string{RootCapability}
|
||||
}
|
||||
|
||||
// Find an exact matching rule, look for glob if no match
|
||||
var capabilities uint32
|
||||
raw, ok := a.exactRules.Get(path)
|
||||
if ok {
|
||||
capabilities = raw.(uint32)
|
||||
goto CHECK
|
||||
}
|
||||
|
||||
// Find a glob rule, default deny if no match
|
||||
_, raw, ok = a.globRules.LongestPrefix(path)
|
||||
if !ok {
|
||||
return []string{DenyCapability}
|
||||
} else {
|
||||
capabilities = raw.(uint32)
|
||||
}
|
||||
|
||||
CHECK:
|
||||
if capabilities&SudoCapabilityInt > 0 {
|
||||
pathCapabilities = append(pathCapabilities, SudoCapability)
|
||||
}
|
||||
if capabilities&ReadCapabilityInt > 0 {
|
||||
pathCapabilities = append(pathCapabilities, ReadCapability)
|
||||
}
|
||||
if capabilities&ListCapabilityInt > 0 {
|
||||
pathCapabilities = append(pathCapabilities, ListCapability)
|
||||
}
|
||||
if capabilities&UpdateCapabilityInt > 0 {
|
||||
pathCapabilities = append(pathCapabilities, UpdateCapability)
|
||||
}
|
||||
if capabilities&DeleteCapabilityInt > 0 {
|
||||
pathCapabilities = append(pathCapabilities, DeleteCapability)
|
||||
}
|
||||
if capabilities&CreateCapabilityInt > 0 {
|
||||
pathCapabilities = append(pathCapabilities, CreateCapability)
|
||||
}
|
||||
|
||||
// If "deny" is explicitly set or if the path has no capabilities at all,
|
||||
// set the path capabilities to "deny"
|
||||
if capabilities&DenyCapabilityInt > 0 || len(pathCapabilities) == 0 {
|
||||
pathCapabilities = []string{DenyCapability}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// AllowOperation is used to check if the given operation is permitted. The
|
||||
// first bool indicates if an op is allowed, the second whether sudo priviliges
|
||||
// exist for that op and path.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,56 @@
|
|||
package vault
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/vault/logical"
|
||||
)
|
||||
|
||||
func TestACL_Capabilities(t *testing.T) {
|
||||
// Create the root policy ACL
|
||||
policy := []*Policy{&Policy{Name: "root"}}
|
||||
acl, err := NewACL(policy)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
||||
actual := acl.Capabilities("any/path")
|
||||
expected := []string{"root"}
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actual, expected)
|
||||
}
|
||||
|
||||
policies, err := Parse(aclPolicy)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
||||
acl, err = NewACL([]*Policy{policies})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
||||
actual = acl.Capabilities("dev")
|
||||
expected = []string{"deny"}
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Fatalf("bad: path:%s\ngot\n%#v\nexpected\n%#v\n", "deny", actual, expected)
|
||||
}
|
||||
|
||||
actual = acl.Capabilities("dev/")
|
||||
expected = []string{"sudo", "read", "list", "update", "delete", "create"}
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Fatalf("bad: path:%s\ngot\n%#v\nexpected\n%#v\n", "dev/", actual, expected)
|
||||
}
|
||||
|
||||
actual = acl.Capabilities("stage/aws/test")
|
||||
expected = []string{"sudo", "read", "list", "update"}
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Fatalf("bad: path:%s\ngot\n%#v\nexpected\n%#v\n", "stage/aws/test", actual, expected)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestACL_Root(t *testing.T) {
|
||||
// Create the root policy ACL
|
||||
policy := []*Policy{&Policy{Name: "root"}}
|
||||
|
|
|
|||
75
vault/capabilities.go
Normal file
75
vault/capabilities.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package vault
|
||||
|
||||
// Struct to identify user input errors.
|
||||
// This is helpful in responding the appropriate status codes to clients
|
||||
// from the HTTP endpoints.
|
||||
type StatusBadRequest struct {
|
||||
Err string
|
||||
}
|
||||
|
||||
// Implementing error interface
|
||||
func (s *StatusBadRequest) Error() string {
|
||||
return s.Err
|
||||
}
|
||||
|
||||
// CapabilitiesAccessor is used to fetch the capabilities of the token
|
||||
// which associated with the given accessor on the given path
|
||||
func (c *Core) CapabilitiesAccessor(accessor, path string) ([]string, error) {
|
||||
if path == "" {
|
||||
return nil, &StatusBadRequest{Err: "missing path"}
|
||||
}
|
||||
|
||||
if accessor == "" {
|
||||
return nil, &StatusBadRequest{Err: "missing accessor"}
|
||||
}
|
||||
|
||||
token, err := c.tokenStore.lookupByAccessor(accessor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.Capabilities(token, path)
|
||||
}
|
||||
|
||||
// Capabilities is used to fetch the capabilities of the given token on the given path
|
||||
func (c *Core) Capabilities(token, path string) ([]string, error) {
|
||||
if path == "" {
|
||||
return nil, &StatusBadRequest{Err: "missing path"}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return nil, &StatusBadRequest{Err: "missing token"}
|
||||
}
|
||||
|
||||
te, err := c.tokenStore.Lookup(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if te == nil {
|
||||
return nil, &StatusBadRequest{Err: "invalid token"}
|
||||
}
|
||||
|
||||
if te.Policies == nil {
|
||||
return []string{DenyCapability}, nil
|
||||
}
|
||||
|
||||
var policies []*Policy
|
||||
for _, tePolicy := range te.Policies {
|
||||
policy, err := c.policyStore.GetPolicy(tePolicy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
policies = append(policies, policy)
|
||||
}
|
||||
|
||||
if len(policies) == 0 {
|
||||
return []string{DenyCapability}, nil
|
||||
}
|
||||
|
||||
acl, err := NewACL(policies)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return acl.Capabilities(path), nil
|
||||
}
|
||||
100
vault/capabilities_test.go
Normal file
100
vault/capabilities_test.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package vault
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCapabilitiesAccessor(t *testing.T) {
|
||||
c, _, token := TestCoreUnsealed(t)
|
||||
|
||||
// Lookup the token in the store to get root token's accessor
|
||||
tokenEntry, err := c.tokenStore.Lookup(token)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
accessor := tokenEntry.Accessor
|
||||
|
||||
// Use the accessor to fetch the capabilities
|
||||
actual, err := c.CapabilitiesAccessor(accessor, "path")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
expected := []string{"root"}
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actual, expected)
|
||||
}
|
||||
|
||||
// Create a policy
|
||||
policy, _ := Parse(aclPolicy)
|
||||
err = c.policyStore.SetPolicy(policy)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
||||
// Create a token for the policy
|
||||
ent := &TokenEntry{
|
||||
ID: "capabilitiestoken",
|
||||
Path: "testpath",
|
||||
Policies: []string{"dev"},
|
||||
}
|
||||
if err := c.tokenStore.create(ent); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
||||
// Lookup the token in the store to get token's accessor
|
||||
tokenEntry, err = c.tokenStore.Lookup("capabilitiestoken")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
accessor = tokenEntry.Accessor
|
||||
|
||||
// Use the accessor to fetch the capabilities
|
||||
actual, err = c.CapabilitiesAccessor(accessor, "foo/bar")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
expected = []string{"sudo", "read", "create"}
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actual, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilities(t *testing.T) {
|
||||
c, _, token := TestCoreUnsealed(t)
|
||||
|
||||
actual, err := c.Capabilities(token, "path")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
expected := []string{"root"}
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actual, expected)
|
||||
}
|
||||
|
||||
// Create a policy
|
||||
policy, _ := Parse(aclPolicy)
|
||||
err = c.policyStore.SetPolicy(policy)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
||||
// Create a token for the policy
|
||||
ent := &TokenEntry{
|
||||
ID: "capabilitiestoken",
|
||||
Path: "testpath",
|
||||
Policies: []string{"dev"},
|
||||
}
|
||||
if err := c.tokenStore.create(ent); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
||||
actual, err = c.Capabilities("capabilitiestoken", "foo/bar")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
expected = []string{"sudo", "read", "create"}
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actual, expected)
|
||||
}
|
||||
}
|
||||
|
|
@ -680,8 +680,9 @@ func (c *Core) handleLoginRequest(req *logical.Request) (*logical.Response, *log
|
|||
return nil, auth, ErrInternalError
|
||||
}
|
||||
|
||||
// Populate the client token
|
||||
// Populate the client token and accessor
|
||||
auth.ClientToken = te.ID
|
||||
auth.Accessor = te.Accessor
|
||||
|
||||
// Register with the expiration manager
|
||||
if err := c.expiration.RegisterAuth(req.Path, auth); err != nil {
|
||||
|
|
|
|||
|
|
@ -787,6 +787,7 @@ func TestCore_HandleLogin_Token(t *testing.T) {
|
|||
}
|
||||
expect := &TokenEntry{
|
||||
ID: clientToken,
|
||||
Accessor: te.Accessor,
|
||||
Parent: "",
|
||||
Policies: []string{"foo", "bar", "default"},
|
||||
Path: "auth/foo/login",
|
||||
|
|
@ -986,6 +987,7 @@ func TestCore_HandleRequest_CreateToken_Lease(t *testing.T) {
|
|||
}
|
||||
expect := &TokenEntry{
|
||||
ID: clientToken,
|
||||
Accessor: te.Accessor,
|
||||
Parent: root,
|
||||
Policies: []string{"default", "foo"},
|
||||
Path: "auth/token/create",
|
||||
|
|
@ -1030,6 +1032,7 @@ func TestCore_HandleRequest_CreateToken_NoDefaultPolicy(t *testing.T) {
|
|||
}
|
||||
expect := &TokenEntry{
|
||||
ID: clientToken,
|
||||
Accessor: te.Accessor,
|
||||
Parent: root,
|
||||
Policies: []string{"foo"},
|
||||
Path: "auth/token/create",
|
||||
|
|
|
|||
|
|
@ -173,6 +173,14 @@ func (m *ExpirationManager) Stop() error {
|
|||
// Revoke is used to revoke a secret named by the given LeaseID
|
||||
func (m *ExpirationManager) Revoke(leaseID string) error {
|
||||
defer metrics.MeasureSince([]string{"expire", "revoke"}, time.Now())
|
||||
|
||||
return m.revokeCommon(leaseID, false)
|
||||
}
|
||||
|
||||
// revokeCommon does the heavy lifting. If force is true, we ignore a problem
|
||||
// during revocation and still remove entries/index/lease timers
|
||||
func (m *ExpirationManager) revokeCommon(leaseID string, force bool) error {
|
||||
defer metrics.MeasureSince([]string{"expire", "revoke-common"}, time.Now())
|
||||
// Load the entry
|
||||
le, err := m.loadEntry(leaseID)
|
||||
if err != nil {
|
||||
|
|
@ -186,7 +194,11 @@ func (m *ExpirationManager) Revoke(leaseID string) error {
|
|||
|
||||
// Revoke the entry
|
||||
if err := m.revokeEntry(le); err != nil {
|
||||
return err
|
||||
if !force {
|
||||
return err
|
||||
} else {
|
||||
m.logger.Printf("[WARN]: revocation from the backend failed, but in force mode so ignoring; error was: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the entry
|
||||
|
|
@ -209,32 +221,21 @@ func (m *ExpirationManager) Revoke(leaseID string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// RevokeForce works similarly to RevokePrefix but continues in the case of a
|
||||
// revocation error; this is mostly meant for recovery operations
|
||||
func (m *ExpirationManager) RevokeForce(prefix string) error {
|
||||
defer metrics.MeasureSince([]string{"expire", "revoke-force"}, time.Now())
|
||||
|
||||
return m.revokePrefixCommon(prefix, true)
|
||||
}
|
||||
|
||||
// RevokePrefix is used to revoke all secrets with a given prefix.
|
||||
// The prefix maps to that of the mount table to make this simpler
|
||||
// to reason about.
|
||||
func (m *ExpirationManager) RevokePrefix(prefix string) error {
|
||||
defer metrics.MeasureSince([]string{"expire", "revoke-prefix"}, time.Now())
|
||||
// Ensure there is a trailing slash
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
prefix = prefix + "/"
|
||||
}
|
||||
|
||||
// Accumulate existing leases
|
||||
sub := m.idView.SubView(prefix)
|
||||
existing, err := CollectKeys(sub)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan for leases: %v", err)
|
||||
}
|
||||
|
||||
// Revoke all the keys
|
||||
for idx, suffix := range existing {
|
||||
leaseID := prefix + suffix
|
||||
if err := m.Revoke(leaseID); err != nil {
|
||||
return fmt.Errorf("failed to revoke '%s' (%d / %d): %v",
|
||||
leaseID, idx+1, len(existing), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return m.revokePrefixCommon(prefix, false)
|
||||
}
|
||||
|
||||
// RevokeByToken is used to revoke all the secrets issued with
|
||||
|
|
@ -257,6 +258,30 @@ func (m *ExpirationManager) RevokeByToken(token string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (m *ExpirationManager) revokePrefixCommon(prefix string, force bool) error {
|
||||
// Ensure there is a trailing slash
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
prefix = prefix + "/"
|
||||
}
|
||||
|
||||
// Accumulate existing leases
|
||||
sub := m.idView.SubView(prefix)
|
||||
existing, err := CollectKeys(sub)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan for leases: %v", err)
|
||||
}
|
||||
|
||||
// Revoke all the keys
|
||||
for idx, suffix := range existing {
|
||||
leaseID := prefix + suffix
|
||||
if err := m.revokeCommon(leaseID, force); err != nil {
|
||||
return fmt.Errorf("failed to revoke '%s' (%d / %d): %v",
|
||||
leaseID, idx+1, len(existing), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Renew is used to renew a secret using the given leaseID
|
||||
// and a renew interval. The increment may be ignored.
|
||||
func (m *ExpirationManager) Renew(leaseID string, increment time.Duration) (*logical.Response, error) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package vault
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
|
@ -9,6 +10,7 @@ import (
|
|||
|
||||
"github.com/hashicorp/go-uuid"
|
||||
"github.com/hashicorp/vault/logical"
|
||||
"github.com/hashicorp/vault/logical/framework"
|
||||
)
|
||||
|
||||
// mockExpiration returns a mock expiration manager
|
||||
|
|
@ -954,3 +956,83 @@ func TestLeaseEntry(t *testing.T) {
|
|||
t.Fatalf("got: %#v, expect %#v", out, le)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiration_RevokeForce(t *testing.T) {
|
||||
core, _, _, root := TestCoreWithTokenStore(t)
|
||||
|
||||
core.logicalBackends["badrenew"] = badRenewFactory
|
||||
me := &MountEntry{
|
||||
Path: "badrenew/",
|
||||
Type: "badrenew",
|
||||
}
|
||||
|
||||
err := core.mount(me)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := &logical.Request{
|
||||
Operation: logical.ReadOperation,
|
||||
Path: "badrenew/creds",
|
||||
ClientToken: root,
|
||||
}
|
||||
|
||||
resp, err := core.HandleRequest(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("response was nil")
|
||||
}
|
||||
if resp.Secret == nil {
|
||||
t.Fatalf("response secret was nil, response was %#v", *resp)
|
||||
}
|
||||
|
||||
req.Operation = logical.UpdateOperation
|
||||
req.Path = "sys/revoke-prefix/badrenew/creds"
|
||||
|
||||
resp, err = core.HandleRequest(req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
req.Path = "sys/revoke-force/badrenew/creds"
|
||||
resp, err = core.HandleRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("got error: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func badRenewFactory(conf *logical.BackendConfig) (logical.Backend, error) {
|
||||
be := &framework.Backend{
|
||||
Paths: []*framework.Path{
|
||||
&framework.Path{
|
||||
Pattern: "creds",
|
||||
Callbacks: map[logical.Operation]framework.OperationFunc{
|
||||
logical.ReadOperation: func(*logical.Request, *framework.FieldData) (*logical.Response, error) {
|
||||
resp := &logical.Response{
|
||||
Secret: &logical.Secret{
|
||||
InternalData: map[string]interface{}{
|
||||
"secret_type": "badRenewBackend",
|
||||
},
|
||||
},
|
||||
}
|
||||
resp.Secret.TTL = time.Second * 30
|
||||
return resp, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Secrets: []*framework.Secret{
|
||||
&framework.Secret{
|
||||
Type: "badRenewBackend",
|
||||
Revoke: func(*logical.Request, *framework.FieldData) (*logical.Response, error) {
|
||||
return nil, fmt.Errorf("always errors")
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return be.Setup(conf)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,6 +185,24 @@ func NewSystemBackend(core *Core, config *logical.BackendConfig) logical.Backend
|
|||
HelpDescription: strings.TrimSpace(sysHelp["revoke"][1]),
|
||||
},
|
||||
|
||||
&framework.Path{
|
||||
Pattern: "revoke-force/(?P<prefix>.+)",
|
||||
|
||||
Fields: map[string]*framework.FieldSchema{
|
||||
"prefix": &framework.FieldSchema{
|
||||
Type: framework.TypeString,
|
||||
Description: strings.TrimSpace(sysHelp["revoke-force-path"][0]),
|
||||
},
|
||||
},
|
||||
|
||||
Callbacks: map[logical.Operation]framework.OperationFunc{
|
||||
logical.UpdateOperation: b.handleRevokeForce,
|
||||
},
|
||||
|
||||
HelpSynopsis: strings.TrimSpace(sysHelp["revoke-force"][0]),
|
||||
HelpDescription: strings.TrimSpace(sysHelp["revoke-force"][1]),
|
||||
},
|
||||
|
||||
&framework.Path{
|
||||
Pattern: "revoke-prefix/(?P<prefix>.+)",
|
||||
|
||||
|
|
@ -736,11 +754,29 @@ func (b *SystemBackend) handleRevoke(
|
|||
// handleRevokePrefix is used to revoke a prefix with many LeaseIDs
|
||||
func (b *SystemBackend) handleRevokePrefix(
|
||||
req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||
return b.handleRevokePrefixCommon(req, data, false)
|
||||
}
|
||||
|
||||
// handleRevokeForce is used to revoke a prefix with many LeaseIDs, ignoring errors
|
||||
func (b *SystemBackend) handleRevokeForce(
|
||||
req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||
return b.handleRevokePrefixCommon(req, data, true)
|
||||
}
|
||||
|
||||
// handleRevokePrefixCommon is used to revoke a prefix with many LeaseIDs
|
||||
func (b *SystemBackend) handleRevokePrefixCommon(
|
||||
req *logical.Request, data *framework.FieldData, force bool) (*logical.Response, error) {
|
||||
// Get all the options
|
||||
prefix := data.Get("prefix").(string)
|
||||
|
||||
// Invoke the expiration manager directly
|
||||
if err := b.Core.expiration.RevokePrefix(prefix); err != nil {
|
||||
var err error
|
||||
if force {
|
||||
err = b.Core.expiration.RevokeForce(prefix)
|
||||
} else {
|
||||
err = b.Core.expiration.RevokePrefix(prefix)
|
||||
}
|
||||
if err != nil {
|
||||
b.Backend.Logger().Printf("[ERR] sys: revoke prefix '%s' failed: %v", prefix, err)
|
||||
return handleError(err)
|
||||
}
|
||||
|
|
@ -1228,6 +1264,23 @@ all matching leases.
|
|||
"",
|
||||
},
|
||||
|
||||
"revoke-force": {
|
||||
"Revoke all secrets generated in a given prefix, ignoring errors.",
|
||||
`
|
||||
See the path help for 'revoke-prefix'; this behaves the same, except that it
|
||||
ignores errors encountered during revocation. This can be used in certain
|
||||
recovery situations; for instance, when you want to unmount a backend, but it
|
||||
is impossible to fix revocation errors and these errors prevent the unmount
|
||||
from proceeding. This is a DANGEROUS operation as it removes Vault's oversight
|
||||
of external secrets. Access to this prefix should be tightly controlled.
|
||||
`,
|
||||
},
|
||||
|
||||
"revoke-force-path": {
|
||||
`The path to revoke keys under. Example: "prod/aws/ops"`,
|
||||
"",
|
||||
},
|
||||
|
||||
"auth-table": {
|
||||
"List the currently enabled credential backends.",
|
||||
`
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const (
|
|||
DeleteCapability = "delete"
|
||||
ListCapability = "list"
|
||||
SudoCapability = "sudo"
|
||||
RootCapability = "root"
|
||||
|
||||
// Backwards compatibility
|
||||
OldDenyPathPolicy = "deny"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ const (
|
|||
// primary ID based index
|
||||
lookupPrefix = "id/"
|
||||
|
||||
// accessorPrefix is the prefix used to store the index from
|
||||
// Accessor to Token ID
|
||||
accessorPrefix = "accessor/"
|
||||
|
||||
// parentPrefix is the prefix used to store tokens for their
|
||||
// secondar parent based index
|
||||
parentPrefix = "parent/"
|
||||
|
|
@ -209,6 +213,24 @@ func NewTokenStore(c *Core, config *logical.BackendConfig) (*TokenStore, error)
|
|||
HelpDescription: strings.TrimSpace(tokenLookupHelp),
|
||||
},
|
||||
|
||||
&framework.Path{
|
||||
Pattern: "lookup-accessor/(?P<accessor>.+)",
|
||||
|
||||
Fields: map[string]*framework.FieldSchema{
|
||||
"accessor": &framework.FieldSchema{
|
||||
Type: framework.TypeString,
|
||||
Description: "Accessor of the token to lookup",
|
||||
},
|
||||
},
|
||||
|
||||
Callbacks: map[logical.Operation]framework.OperationFunc{
|
||||
logical.UpdateOperation: t.handleUpdateLookupAccessor,
|
||||
},
|
||||
|
||||
HelpSynopsis: strings.TrimSpace(tokenLookupAccessorHelp),
|
||||
HelpDescription: strings.TrimSpace(tokenLookupAccessorHelp),
|
||||
},
|
||||
|
||||
&framework.Path{
|
||||
Pattern: "lookup-self$",
|
||||
|
||||
|
|
@ -227,6 +249,24 @@ func NewTokenStore(c *Core, config *logical.BackendConfig) (*TokenStore, error)
|
|||
HelpDescription: strings.TrimSpace(tokenLookupHelp),
|
||||
},
|
||||
|
||||
&framework.Path{
|
||||
Pattern: "revoke-accessor/(?P<accessor>.+)",
|
||||
|
||||
Fields: map[string]*framework.FieldSchema{
|
||||
"accessor": &framework.FieldSchema{
|
||||
Type: framework.TypeString,
|
||||
Description: "Accessor of the token",
|
||||
},
|
||||
},
|
||||
|
||||
Callbacks: map[logical.Operation]framework.OperationFunc{
|
||||
logical.UpdateOperation: t.handleUpdateRevokeAccessor,
|
||||
},
|
||||
|
||||
HelpSynopsis: strings.TrimSpace(tokenRevokeAccessorHelp),
|
||||
HelpDescription: strings.TrimSpace(tokenRevokeAccessorHelp),
|
||||
},
|
||||
|
||||
&framework.Path{
|
||||
Pattern: "revoke-self$",
|
||||
|
||||
|
|
@ -348,6 +388,7 @@ func NewTokenStore(c *Core, config *logical.BackendConfig) (*TokenStore, error)
|
|||
// TokenEntry is used to represent a given token
|
||||
type TokenEntry struct {
|
||||
ID string // ID of this entry, generally a random UUID
|
||||
Accessor string // Accessor for this token, a random UUID
|
||||
Parent string // Parent token, used for revocation trees
|
||||
Policies []string // Which named policies should be used
|
||||
Path string // Used for audit trails, this is something like "auth/user/login"
|
||||
|
|
@ -406,6 +447,27 @@ func (ts *TokenStore) rootToken() (*TokenEntry, error) {
|
|||
return te, nil
|
||||
}
|
||||
|
||||
// createAccessor is used to create an identifier for the token ID.
|
||||
// A storage index, mapping the accessor to the token ID is also created.
|
||||
func (ts *TokenStore) createAccessor(entry *TokenEntry) error {
|
||||
defer metrics.MeasureSince([]string{"token", "createAccessor"}, time.Now())
|
||||
|
||||
// Create a random accessor
|
||||
accessorUUID, err := uuid.GenerateUUID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.Accessor = accessorUUID
|
||||
|
||||
// Create index entry, mapping the accessor to the token ID
|
||||
path := accessorPrefix + ts.SaltID(entry.Accessor)
|
||||
le := &logical.StorageEntry{Key: path, Value: []byte(entry.ID)}
|
||||
if err := ts.view.Put(le); err != nil {
|
||||
return fmt.Errorf("failed to persist accessor index entry: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create is used to create a new token entry. The entry is assigned
|
||||
// a newly generated ID if not provided.
|
||||
func (ts *TokenStore) create(entry *TokenEntry) error {
|
||||
|
|
@ -419,6 +481,11 @@ func (ts *TokenStore) create(entry *TokenEntry) error {
|
|||
entry.ID = entryUUID
|
||||
}
|
||||
|
||||
err := ts.createAccessor(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ts.storeCommon(entry, true)
|
||||
}
|
||||
|
||||
|
|
@ -575,6 +642,14 @@ func (ts *TokenStore) revokeSalted(saltedId string) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Clear the accessor index if any
|
||||
if entry != nil && entry.Accessor != "" {
|
||||
path := accessorPrefix + ts.SaltID(entry.Accessor)
|
||||
if ts.view.Delete(path); err != nil {
|
||||
return fmt.Errorf("failed to delete entry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke all secrets under this token
|
||||
if entry != nil {
|
||||
if err := ts.expiration.RevokeByToken(entry.ID); err != nil {
|
||||
|
|
@ -651,6 +726,83 @@ func (ts *TokenStore) handleCreateAgainstRole(
|
|||
return ts.handleCreateCommon(req, d, false, roleEntry)
|
||||
}
|
||||
|
||||
func (ts *TokenStore) lookupByAccessor(accessor string) (string, error) {
|
||||
entry, err := ts.view.Get(accessorPrefix + ts.SaltID(accessor))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read index using accessor: %s", err)
|
||||
}
|
||||
if entry == nil {
|
||||
return "", &StatusBadRequest{Err: "invalid accessor"}
|
||||
}
|
||||
|
||||
return string(entry.Value), nil
|
||||
}
|
||||
|
||||
// handleUpdateLookupAccessor handles the auth/token/lookup-accessor path for returning
|
||||
// the properties of the token associated with the accessor
|
||||
func (ts *TokenStore) handleUpdateLookupAccessor(req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||
accessor := data.Get("accessor").(string)
|
||||
if accessor == "" {
|
||||
return nil, &StatusBadRequest{Err: "missing accessor"}
|
||||
}
|
||||
|
||||
tokenID, err := ts.lookupByAccessor(accessor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Prepare the field data required for a lookup call
|
||||
d := &framework.FieldData{
|
||||
Raw: map[string]interface{}{
|
||||
"token": tokenID,
|
||||
},
|
||||
Schema: map[string]*framework.FieldSchema{
|
||||
"token": &framework.FieldSchema{
|
||||
Type: framework.TypeString,
|
||||
Description: "Token to lookup",
|
||||
},
|
||||
},
|
||||
}
|
||||
resp, err := ts.handleLookup(req, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, fmt.Errorf("failed to lookup the token")
|
||||
}
|
||||
if resp.IsError() {
|
||||
return resp, nil
|
||||
|
||||
}
|
||||
|
||||
// Remove the token ID from the response
|
||||
if resp.Data != nil {
|
||||
resp.Data["id"] = ""
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// handleUpdateRevokeAccessor handles the auth/token/revoke-accessor path for revoking
|
||||
// the token associated with the accessor
|
||||
func (ts *TokenStore) handleUpdateRevokeAccessor(req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||
accessor := data.Get("accessor").(string)
|
||||
if accessor == "" {
|
||||
return nil, &StatusBadRequest{Err: "missing accessor"}
|
||||
}
|
||||
|
||||
tokenID, err := ts.lookupByAccessor(accessor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Revoke the token and its children
|
||||
if err := ts.RevokeTree(tokenID); err != nil {
|
||||
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// handleCreate handles the auth/token/create path for creation of new orphan
|
||||
// tokens
|
||||
func (ts *TokenStore) handleCreateOrphan(
|
||||
|
|
@ -864,6 +1016,7 @@ func (ts *TokenStore) handleCreateCommon(
|
|||
Renewable: true,
|
||||
},
|
||||
ClientToken: te.ID,
|
||||
Accessor: te.Accessor,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -990,6 +1143,7 @@ func (ts *TokenStore) handleLookup(
|
|||
resp := &logical.Response{
|
||||
Data: map[string]interface{}{
|
||||
"id": out.ID,
|
||||
"accessor": out.Accessor,
|
||||
"policies": out.Policies,
|
||||
"path": out.Path,
|
||||
"meta": out.Meta,
|
||||
|
|
@ -1262,8 +1416,10 @@ as revocation of tokens. The tokens are renewable if associated with a lease.`
|
|||
tokenCreateOrphanHelp = `The token create path is used to create new orphan tokens.`
|
||||
tokenCreateRoleHelp = `This token create path is used to create new tokens adhering to the given role.`
|
||||
tokenListRolesHelp = `This endpoint lists configured roles.`
|
||||
tokenLookupAccessorHelp = `This endpoint will lookup a token associated with the given accessor and its properties. Response will not contain the token ID.`
|
||||
tokenLookupHelp = `This endpoint will lookup a token and its properties.`
|
||||
tokenPathRolesHelp = `This endpoint allows creating, reading, and deleting roles.`
|
||||
tokenRevokeAccessorHelp = `This endpoint will delete the token associated with the accessor and all of its child tokens.`
|
||||
tokenRevokeHelp = `This endpoint will delete the given token and all of its child tokens.`
|
||||
tokenRevokeSelfHelp = `This endpoint will delete the token used to call it and all of its child tokens.`
|
||||
tokenRevokeOrphanHelp = `This endpoint will delete the token and orphan its child tokens.`
|
||||
|
|
|
|||
|
|
@ -53,6 +53,94 @@ func testCoreMakeToken(t *testing.T, c *Core, root, client, ttl string, policy [
|
|||
}
|
||||
}
|
||||
|
||||
func TestTokenStore_AccessorIndex(t *testing.T) {
|
||||
_, ts, _, _ := TestCoreWithTokenStore(t)
|
||||
|
||||
ent := &TokenEntry{Path: "test", Policies: []string{"dev", "ops"}}
|
||||
if err := ts.create(ent); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
out, err := ts.Lookup(ent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
// Ensure that accessor is created
|
||||
if out == nil || out.Accessor == "" {
|
||||
t.Fatalf("bad: %#v", out)
|
||||
}
|
||||
|
||||
token, err := ts.lookupByAccessor(out.Accessor)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
// Verify that the value returned from the index matches the token ID
|
||||
if token != ent.ID {
|
||||
t.Fatalf("bad: got\n%s\nexpected\n%s\n", token, ent.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStore_HandleRequest_LookupAccessor(t *testing.T) {
|
||||
_, ts, _, root := TestCoreWithTokenStore(t)
|
||||
testMakeToken(t, ts, root, "tokenid", "", []string{"foo"})
|
||||
out, err := ts.Lookup("tokenid")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
req := logical.TestRequest(t, logical.UpdateOperation, "lookup-accessor/"+out.Accessor)
|
||||
|
||||
resp, err := ts.HandleRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
if resp.Data == nil {
|
||||
t.Fatalf("response should contain data")
|
||||
}
|
||||
|
||||
if resp.Data["accessor"].(string) == "" {
|
||||
t.Fatalf("accessor should not be empty")
|
||||
}
|
||||
|
||||
// Verify that the lookup-accessor operation does not return the token ID
|
||||
if resp.Data["id"].(string) != "" {
|
||||
t.Fatalf("token ID should not be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStore_HandleRequest_RevokeAccessor(t *testing.T) {
|
||||
_, ts, _, root := TestCoreWithTokenStore(t)
|
||||
testMakeToken(t, ts, root, "tokenid", "", []string{"foo"})
|
||||
out, err := ts.Lookup("tokenid")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
req := logical.TestRequest(t, logical.UpdateOperation, "revoke-accessor/"+out.Accessor)
|
||||
|
||||
_, err = ts.HandleRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
out, err = ts.Lookup("tokenid")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
if out != nil {
|
||||
t.Fatalf("bad:\ngot %#v\nexpected: nil\n", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStore_RootToken(t *testing.T) {
|
||||
_, ts, _, _ := TestCoreWithTokenStore(t)
|
||||
|
||||
|
|
@ -417,6 +505,7 @@ func TestTokenStore_HandleRequest_CreateToken_DisplayName(t *testing.T) {
|
|||
|
||||
expected := &TokenEntry{
|
||||
ID: resp.Auth.ClientToken,
|
||||
Accessor: resp.Auth.Accessor,
|
||||
Parent: root,
|
||||
Policies: []string{"root"},
|
||||
Path: "auth/token/create",
|
||||
|
|
@ -447,6 +536,7 @@ func TestTokenStore_HandleRequest_CreateToken_NumUses(t *testing.T) {
|
|||
|
||||
expected := &TokenEntry{
|
||||
ID: resp.Auth.ClientToken,
|
||||
Accessor: resp.Auth.Accessor,
|
||||
Parent: root,
|
||||
Policies: []string{"root"},
|
||||
Path: "auth/token/create",
|
||||
|
|
@ -510,6 +600,7 @@ func TestTokenStore_HandleRequest_CreateToken_NoPolicy(t *testing.T) {
|
|||
|
||||
expected := &TokenEntry{
|
||||
ID: resp.Auth.ClientToken,
|
||||
Accessor: resp.Auth.Accessor,
|
||||
Parent: root,
|
||||
Policies: []string{"root"},
|
||||
Path: "auth/token/create",
|
||||
|
|
@ -866,6 +957,7 @@ func TestTokenStore_HandleRequest_Lookup(t *testing.T) {
|
|||
|
||||
exp := map[string]interface{}{
|
||||
"id": root,
|
||||
"accessor": resp.Data["accessor"].(string),
|
||||
"policies": []string{"root"},
|
||||
"path": "auth/token/root",
|
||||
"meta": map[string]string(nil),
|
||||
|
|
@ -899,6 +991,7 @@ func TestTokenStore_HandleRequest_Lookup(t *testing.T) {
|
|||
|
||||
exp = map[string]interface{}{
|
||||
"id": "client",
|
||||
"accessor": resp.Data["accessor"],
|
||||
"policies": []string{"default", "foo"},
|
||||
"path": "auth/token/create",
|
||||
"meta": map[string]string(nil),
|
||||
|
|
@ -1002,6 +1095,7 @@ func TestTokenStore_HandleRequest_LookupSelf(t *testing.T) {
|
|||
|
||||
exp := map[string]interface{}{
|
||||
"id": root,
|
||||
"accessor": resp.Data["accessor"],
|
||||
"policies": []string{"root"},
|
||||
"path": "auth/token/root",
|
||||
"meta": map[string]string(nil),
|
||||
|
|
|
|||
|
|
@ -584,3 +584,85 @@ of the header should be "X-Vault-Token" and the value should be the token.
|
|||
</dd>
|
||||
</dl>
|
||||
|
||||
### /auth/token/lookup-accessor
|
||||
#### POST
|
||||
|
||||
<dl class="api">
|
||||
<dt>Description</dt>
|
||||
<dd>
|
||||
Fetch the properties of the token associated with the accessor, except the token ID.
|
||||
This is meant for purposes where there is no access to token ID but there is need
|
||||
to fetch the properties of a token.
|
||||
</dd>
|
||||
|
||||
<dt>Method</dt>
|
||||
<dd>POST</dd>
|
||||
|
||||
<dt>URL</dt>
|
||||
<dd>`/auth/token/lookup-accessor`</dd>
|
||||
|
||||
<dt>Parameters</dt>
|
||||
<dd>
|
||||
<ul>
|
||||
<li>
|
||||
<span class="param">accessor</span>
|
||||
<span class="param-flags">required</span>
|
||||
Accessor of the token to lookup.
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
|
||||
<dt>Returns</dt>
|
||||
<dd>
|
||||
|
||||
```javascript
|
||||
{
|
||||
"lease_id": "",
|
||||
"renewable": false,
|
||||
"lease_duration": 0,
|
||||
"data": {
|
||||
"creation_time": 1457533232,
|
||||
"creation_ttl": 2592000,
|
||||
"display_name": "token",
|
||||
"id": "",
|
||||
"meta": null,
|
||||
"num_uses": 0,
|
||||
"orphan": false,
|
||||
"path": "auth/token/create",
|
||||
"policies": ["default", "web"],
|
||||
"ttl": 2591976
|
||||
},
|
||||
"warnings": null,
|
||||
"auth": null
|
||||
}
|
||||
```
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
### /auth/token/revoke-accessor/
|
||||
#### POST
|
||||
|
||||
<dl class="api">
|
||||
<dt>Description</dt>
|
||||
<dd>
|
||||
Revoke the token associated with the accessor and all the child tokens.
|
||||
This is meant for purposes where there is no access to token ID but
|
||||
there is need to revoke a token and its children.
|
||||
</dd>
|
||||
|
||||
<dt>Method</dt>
|
||||
<dd>POST</dd>
|
||||
|
||||
<dt>URL</dt>
|
||||
<dd>`/auth/token/revoke-accessor/<accessor>`</dd>
|
||||
|
||||
<dt>Parameters</dt>
|
||||
<dd>
|
||||
None
|
||||
</dd>
|
||||
|
||||
<dt>Returns</dt>
|
||||
<dd>`204` response code.
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
|
|
|
|||
48
website/source/docs/http/sys-capabilities-accessor.html.md
Normal file
48
website/source/docs/http/sys-capabilities-accessor.html.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
layout: "http"
|
||||
page_title: "HTTP API: /sys/capabilities-accessor"
|
||||
sidebar_current: "docs-http-auth-capabilities-accessor"
|
||||
description: |-
|
||||
The `/sys/capabilities-accessor` endpoint is used to fetch the capabilities of the token associated with an accessor, on the given path.
|
||||
---
|
||||
|
||||
# /sys/capabilities-accessor
|
||||
|
||||
## POST
|
||||
|
||||
<dl>
|
||||
<dt>Description</dt>
|
||||
<dd>
|
||||
Returns the capabilities of the token associated with an accessor, on the given path.
|
||||
</dd>
|
||||
|
||||
<dt>Method</dt>
|
||||
<dd>POST</dd>
|
||||
|
||||
<dt>Parameters</dt>
|
||||
<dd>
|
||||
<ul>
|
||||
<li>
|
||||
<span class="param">accessor</span>
|
||||
<span class="param-flags">required</span>
|
||||
Accessor of the token.
|
||||
</li>
|
||||
<li>
|
||||
<span class="param">path</span>
|
||||
<span class="param-flags">required</span>
|
||||
Path on which the token's capabilities will be checked.
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
|
||||
<dt>Returns</dt>
|
||||
<dd>
|
||||
|
||||
```javascript
|
||||
{
|
||||
"capabilities": ["read", "list"]
|
||||
}
|
||||
```
|
||||
|
||||
</dd>
|
||||
</dl>
|
||||
44
website/source/docs/http/sys-capabilities-self.html.md
Normal file
44
website/source/docs/http/sys-capabilities-self.html.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
layout: "http"
|
||||
page_title: "HTTP API: /sys/capabilities-self"
|
||||
sidebar_current: "docs-http-auth-capabilities-self"
|
||||
description: |-
|
||||
The `/sys/capabilities-self` endpoint is used to fetch the capabilities of client token on a given path.
|
||||
---
|
||||
|
||||
# /sys/capabilities-self
|
||||
|
||||
## POST
|
||||
|
||||
<dl>
|
||||
<dt>Description</dt>
|
||||
<dd>
|
||||
Returns the capabilities of client token on the given path.
|
||||
Client token is the Vault token with which this API call is made.
|
||||
</dd>
|
||||
|
||||
<dt>Method</dt>
|
||||
<dd>POST</dd>
|
||||
|
||||
<dt>Parameters</dt>
|
||||
<dd>
|
||||
<ul>
|
||||
<li>
|
||||
<span class="param">path</span>
|
||||
<span class="param-flags">required</span>
|
||||
Path on which the client token's capabilities will be checked.
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
|
||||
<dt>Returns</dt>
|
||||
<dd>
|
||||
|
||||
```javascript
|
||||
{
|
||||
"capabilities": ["read", "list"]
|
||||
}
|
||||
```
|
||||
|
||||
</dd>
|
||||
</dl>
|
||||
48
website/source/docs/http/sys-capabilities.html.md
Normal file
48
website/source/docs/http/sys-capabilities.html.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
layout: "http"
|
||||
page_title: "HTTP API: /sys/capabilities"
|
||||
sidebar_current: "docs-http-auth-capabilities"
|
||||
description: |-
|
||||
The `/sys/capabilities` endpoint is used to fetch the capabilities of a token on a given path.
|
||||
---
|
||||
|
||||
# /sys/capabilities
|
||||
|
||||
## POST
|
||||
|
||||
<dl>
|
||||
<dt>Description</dt>
|
||||
<dd>
|
||||
Returns the capabilities of the token on the given path.
|
||||
</dd>
|
||||
|
||||
<dt>Method</dt>
|
||||
<dd>POST</dd>
|
||||
|
||||
<dt>Parameters</dt>
|
||||
<dd>
|
||||
<ul>
|
||||
<li>
|
||||
<span class="param">token</span>
|
||||
<span class="param-flags">required</span>
|
||||
Token for which capabilities are being queried.
|
||||
</li>
|
||||
<li>
|
||||
<span class="param">path</span>
|
||||
<span class="param-flags">required</span>
|
||||
Path on which the token's capabilities will be checked.
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
|
||||
<dt>Returns</dt>
|
||||
<dd>
|
||||
|
||||
```javascript
|
||||
{
|
||||
"capabilities": ["read", "list"]
|
||||
}
|
||||
```
|
||||
|
||||
</dd>
|
||||
</dl>
|
||||
36
website/source/docs/http/sys-revoke-force.html.md
Normal file
36
website/source/docs/http/sys-revoke-force.html.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
layout: "http"
|
||||
page_title: "HTTP API: /sys/revoke-force"
|
||||
sidebar_current: "docs-http-lease-revoke-force"
|
||||
description: |-
|
||||
The `/sys/revoke-force` endpoint is used to revoke secrets based on prefix while ignoring backend errors.
|
||||
---
|
||||
|
||||
# /sys/revoke-force
|
||||
|
||||
<dl>
|
||||
<dt>Description</dt>
|
||||
<dd>
|
||||
Revoke all secrets generated under a given prefix immediately. Unlike
|
||||
`/sys/revoke-prefix`, this path ignores backend errors encountered during
|
||||
revocation. This is <i>potentially very dangerous</i> and should only be
|
||||
used in specific emergency situations where errors in the backend or the
|
||||
connected backend service prevent normal revocation. <i>By ignoring these
|
||||
errors, Vault abdicates responsibility for ensuring that the issued
|
||||
credentials or secrets are properly revoked and/or cleaned up. Access to
|
||||
this endpoint should be tightly controlled.</i>
|
||||
</dd>
|
||||
|
||||
<dt>Method</dt>
|
||||
<dd>PUT</dd>
|
||||
|
||||
<dt>URL</dt>
|
||||
<dd>`/sys/revoke-force/<path prefix>`</dd>
|
||||
|
||||
<dt>Parameters</dt>
|
||||
<dd>None</dd>
|
||||
|
||||
<dt>Returns</dt>
|
||||
<dd>A `204` response code.
|
||||
</dd>
|
||||
</dl>
|
||||
|
|
@ -156,14 +156,15 @@ The root credentials need permission to perform various IAM actions. These are t
|
|||
"Action": [
|
||||
"iam:CreateAccessKey",
|
||||
"iam:CreateUser",
|
||||
"iam:PutUserPolicy",
|
||||
"iam:DeleteAccessKey",
|
||||
"iam:DeleteUser"
|
||||
"iam:DeleteUserPolicy",
|
||||
"iam:ListAccessKeys",
|
||||
"iam:ListAttachedUserPolicies",
|
||||
"iam:ListGroupsForUser",
|
||||
"iam:ListUserPolicies",
|
||||
"iam:ListAccessKeys",
|
||||
"iam:DeleteAccessKey",
|
||||
"iam:DeleteUserPolicy",
|
||||
"iam:PutUserPolicy",
|
||||
"iam:RemoveUserFromGroup",
|
||||
"iam:DeleteUser"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:iam::ACCOUNT-ID-WITHOUT-HYPHENS:user/vault-*"
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ an ACL token to use with the `token` parameter. Vault must have a management
|
|||
type token so that it can create and revoke ACL tokens.
|
||||
|
||||
The next step is to configure a role. A role is a logical name that maps
|
||||
to a role used to generated those credentials. For example, lets create
|
||||
to a role used to generate those credentials. For example, lets create
|
||||
a "readonly" role:
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -69,6 +69,18 @@
|
|||
<li<%= sidebar_current("docs-http-auth-policy") %>>
|
||||
<a href="/docs/http/sys-policy.html">/sys/policy</a>
|
||||
</li>
|
||||
|
||||
<li<%= sidebar_current("docs-http-auth-capabilities") %>>
|
||||
<a href="/docs/http/sys-capabilities.html">/sys/capabilities</a>
|
||||
</li>
|
||||
|
||||
<li<%= sidebar_current("docs-http-auth-capabilities-self") %>>
|
||||
<a href="/docs/http/sys-capabilities-self.html">/sys/capabilities-self</a>
|
||||
</li>
|
||||
|
||||
<li<%= sidebar_current("docs-http-auth-capabilities-accessor") %>>
|
||||
<a href="/docs/http/sys-capabilities-accessor.html">/sys/capabilities-accessor</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
|
@ -98,6 +110,10 @@
|
|||
<li<%= sidebar_current("docs-http-lease-revoke-prefix") %>>
|
||||
<a href="/docs/http/sys-revoke-prefix.html">/sys/revoke-prefix</a>
|
||||
</li>
|
||||
|
||||
<li<%= sidebar_current("docs-http-lease-revoke-force") %>>
|
||||
<a href="/docs/http/sys-revoke-force.html">/sys/revoke-force</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue