etcd/etcdserver/etcdhttp/client_security.go
Barak Michener fa74e702d8 security: Improve the security api as per the suggestions list in #2384
Subcommits:

decouple root and security enable/disable

create root role

prefix matching

godep: bump go-etcd to include credentials

add godep for speakeasy and auth entry parsing

appropriate errors for security enable/disable

WIP adding to etcd/client all the security client methods

add guest access

minor ui return tweaks

revert client changes

respond to comments, log more security operations

fix major ensure() bug, add better UX

block recursive access

fix some boneheaded mistakes

fix integration test

last comments

fix up security_api.md

philips nits

fix docs
2015-04-23 16:11:38 -04:00

387 lines
9.5 KiB
Go

// Copyright 2015 CoreOS, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package etcdhttp
import (
"encoding/json"
"log"
"net/http"
"path"
"strings"
"github.com/coreos/etcd/etcdserver"
"github.com/coreos/etcd/etcdserver/etcdhttp/httptypes"
"github.com/coreos/etcd/etcdserver/security"
"github.com/coreos/etcd/pkg/netutil"
)
type securityHandler struct {
sec *security.Store
clusterInfo etcdserver.ClusterInfo
}
func hasWriteRootAccess(sec *security.Store, r *http.Request) bool {
if r.Method == "GET" || r.Method == "HEAD" {
return true
}
return hasRootAccess(sec, r)
}
func hasRootAccess(sec *security.Store, r *http.Request) bool {
if sec == nil {
// No store means no security available, eg, tests.
return true
}
if !sec.SecurityEnabled() {
return true
}
username, password, ok := netutil.BasicAuth(r)
if !ok {
return false
}
rootUser, err := sec.GetUser(username)
if err != nil {
return false
}
ok = rootUser.CheckPassword(password)
if !ok {
log.Printf("security: Wrong password for user %s", username)
return false
}
for _, role := range rootUser.Roles {
if role == security.RootRoleName {
return true
}
}
log.Printf("security: User %s does not have the %s role for resource %s.", username, security.RootRoleName, r.URL.Path)
return false
}
func hasKeyPrefixAccess(sec *security.Store, r *http.Request, key string, recursive bool) bool {
if sec == nil {
// No store means no security available, eg, tests.
return true
}
if !sec.SecurityEnabled() {
return true
}
username, password, ok := netutil.BasicAuth(r)
if !ok {
return hasGuestAccess(sec, r, key)
}
user, err := sec.GetUser(username)
if err != nil {
log.Printf("security: No such user: %s.", username)
return false
}
authAsUser := user.CheckPassword(password)
if !authAsUser {
log.Printf("security: Incorrect password for user: %s.", username)
return false
}
writeAccess := r.Method != "GET" && r.Method != "HEAD"
for _, roleName := range user.Roles {
role, err := sec.GetRole(roleName)
if err != nil {
continue
}
if recursive {
return role.HasRecursiveAccess(key, writeAccess)
}
return role.HasKeyAccess(key, writeAccess)
}
log.Printf("security: Invalid access for user %s on key %s.", username, key)
return false
}
func hasGuestAccess(sec *security.Store, r *http.Request, key string) bool {
writeAccess := r.Method != "GET" && r.Method != "HEAD"
role, err := sec.GetRole(security.GuestRoleName)
if err != nil {
return false
}
if role.HasKeyAccess(key, writeAccess) {
return true
}
log.Printf("security: Invalid access for unauthenticated user on resource %s.", key)
return false
}
func writeNoAuth(w http.ResponseWriter) {
herr := httptypes.NewHTTPError(http.StatusUnauthorized, "Insufficient credentials")
herr.WriteTo(w)
}
func handleSecurity(mux *http.ServeMux, sh *securityHandler) {
mux.HandleFunc(securityPrefix+"/roles", sh.baseRoles)
mux.HandleFunc(securityPrefix+"/roles/", sh.handleRoles)
mux.HandleFunc(securityPrefix+"/users", sh.baseUsers)
mux.HandleFunc(securityPrefix+"/users/", sh.handleUsers)
mux.HandleFunc(securityPrefix+"/enable", sh.enableDisable)
}
func (sh *securityHandler) baseRoles(w http.ResponseWriter, r *http.Request) {
if !allowMethod(w, r.Method, "GET") {
return
}
if !hasRootAccess(sh.sec, r) {
writeNoAuth(w)
return
}
w.Header().Set("X-Etcd-Cluster-ID", sh.clusterInfo.ID().String())
w.Header().Set("Content-Type", "application/json")
var rolesCollections struct {
Roles []string `json:"roles"`
}
roles, err := sh.sec.AllRoles()
if err != nil {
writeError(w, err)
return
}
if roles == nil {
roles = make([]string, 0)
}
rolesCollections.Roles = roles
err = json.NewEncoder(w).Encode(rolesCollections)
if err != nil {
log.Println("etcdhttp: baseRoles error encoding on", r.URL)
}
}
func (sh *securityHandler) handleRoles(w http.ResponseWriter, r *http.Request) {
subpath := path.Clean(r.URL.Path[len(securityPrefix):])
// Split "/roles/rolename/command".
// First item is an empty string, second is "roles"
pieces := strings.Split(subpath, "/")
if len(pieces) == 2 {
sh.baseRoles(w, r)
return
}
if len(pieces) != 3 {
writeError(w, httptypes.NewHTTPError(http.StatusBadRequest, "Invalid path"))
return
}
sh.forRole(w, r, pieces[2])
}
func (sh *securityHandler) forRole(w http.ResponseWriter, r *http.Request, role string) {
if !allowMethod(w, r.Method, "GET", "PUT", "DELETE") {
return
}
if !hasRootAccess(sh.sec, r) {
writeNoAuth(w)
return
}
w.Header().Set("X-Etcd-Cluster-ID", sh.clusterInfo.ID().String())
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "application/json")
data, err := sh.sec.GetRole(role)
if err != nil {
writeError(w, err)
return
}
err = json.NewEncoder(w).Encode(data)
if err != nil {
log.Println("etcdhttp: forRole error encoding on", r.URL)
return
}
return
case "PUT":
var in security.Role
err := json.NewDecoder(r.Body).Decode(&in)
if err != nil {
writeError(w, httptypes.NewHTTPError(http.StatusBadRequest, "Invalid JSON in request body."))
return
}
if in.Role != role {
writeError(w, httptypes.NewHTTPError(401, "Role JSON name does not match the name in the URL"))
return
}
newrole, created, err := sh.sec.CreateOrUpdateRole(in)
if err != nil {
writeError(w, err)
return
}
if created {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusOK)
}
err = json.NewEncoder(w).Encode(newrole)
if err != nil {
log.Println("etcdhttp: forRole error encoding on", r.URL)
return
}
return
case "DELETE":
err := sh.sec.DeleteRole(role)
if err != nil {
writeError(w, err)
return
}
}
}
func (sh *securityHandler) baseUsers(w http.ResponseWriter, r *http.Request) {
if !allowMethod(w, r.Method, "GET") {
return
}
if !hasRootAccess(sh.sec, r) {
writeNoAuth(w)
return
}
w.Header().Set("X-Etcd-Cluster-ID", sh.clusterInfo.ID().String())
w.Header().Set("Content-Type", "application/json")
var usersCollections struct {
Users []string `json:"users"`
}
users, err := sh.sec.AllUsers()
if err != nil {
writeError(w, err)
return
}
if users == nil {
users = make([]string, 0)
}
usersCollections.Users = users
err = json.NewEncoder(w).Encode(usersCollections)
if err != nil {
log.Println("etcdhttp: baseUsers error encoding on", r.URL)
}
}
func (sh *securityHandler) handleUsers(w http.ResponseWriter, r *http.Request) {
subpath := path.Clean(r.URL.Path[len(securityPrefix):])
// Split "/users/username".
// First item is an empty string, second is "users"
pieces := strings.Split(subpath, "/")
if len(pieces) == 2 {
sh.baseUsers(w, r)
return
}
if len(pieces) != 3 {
writeError(w, httptypes.NewHTTPError(http.StatusBadRequest, "Invalid path"))
return
}
sh.forUser(w, r, pieces[2])
}
func (sh *securityHandler) forUser(w http.ResponseWriter, r *http.Request, user string) {
if !allowMethod(w, r.Method, "GET", "PUT", "DELETE") {
return
}
if !hasRootAccess(sh.sec, r) {
writeNoAuth(w)
return
}
w.Header().Set("X-Etcd-Cluster-ID", sh.clusterInfo.ID().String())
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "application/json")
u, err := sh.sec.GetUser(user)
if err != nil {
writeError(w, err)
return
}
u.Password = ""
err = json.NewEncoder(w).Encode(u)
if err != nil {
log.Println("etcdhttp: forUser error encoding on", r.URL)
return
}
return
case "PUT":
var u security.User
err := json.NewDecoder(r.Body).Decode(&u)
if err != nil {
writeError(w, httptypes.NewHTTPError(http.StatusBadRequest, "Invalid JSON in request body."))
return
}
if u.User != user {
writeError(w, httptypes.NewHTTPError(400, "User JSON name does not match the name in the URL"))
return
}
newuser, created, err := sh.sec.CreateOrUpdateUser(u)
if err != nil {
writeError(w, err)
return
}
if u.Password == "" {
newuser.Password = ""
}
if created {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusOK)
}
err = json.NewEncoder(w).Encode(newuser)
if err != nil {
log.Println("etcdhttp: forUser error encoding on", r.URL)
return
}
return
case "DELETE":
err := sh.sec.DeleteUser(user)
if err != nil {
writeError(w, err)
return
}
}
}
type enabled struct {
Enabled bool `json:"enabled"`
}
func (sh *securityHandler) enableDisable(w http.ResponseWriter, r *http.Request) {
if !allowMethod(w, r.Method, "GET", "PUT", "DELETE") {
return
}
if !hasWriteRootAccess(sh.sec, r) {
writeNoAuth(w)
return
}
w.Header().Set("X-Etcd-Cluster-ID", sh.clusterInfo.ID().String())
w.Header().Set("Content-Type", "application/json")
isEnabled := sh.sec.SecurityEnabled()
switch r.Method {
case "GET":
jsonDict := enabled{isEnabled}
err := json.NewEncoder(w).Encode(jsonDict)
if err != nil {
log.Println("etcdhttp: error encoding security state on", r.URL)
}
case "PUT":
err := sh.sec.EnableSecurity()
if err != nil {
writeError(w, err)
return
}
case "DELETE":
err := sh.sec.DisableSecurity()
if err != nil {
writeError(w, err)
return
}
}
}