Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Read-only join permission #1055

Merged
merged 4 commits into from Jul 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
@@ -1,4 +1,7 @@
## 7.5.2 (unreleased)
## 7.6.0 (unreleased)
### New features
- [PR #1055](https://github.com/rqlite/rqlite/pull/1055): Add new `join-read-only` permission.

### Implementation changes and bug fixes
- [PR #1049](https://github.com/rqlite/rqlite/pull/1049): Ignore freshness when serving queries on Leader. Fixes [issue #1048](https://github.com/rqlite/rqlite/issues/1048). Thanks to @Tjstretchalot for the bug report.

Expand Down
7 changes: 4 additions & 3 deletions DOC/SECURITY.md
Expand Up @@ -45,6 +45,7 @@ rqlite, via the configuration file, also supports user-level permissions. Each u
- _status_: user can retrieve node status and Go runtime information.
- _ready_: user can retrieve node readiness.
- _join_: user can join a cluster. In practice only a node joins a cluster, so it's the joining node that must supply the credentials.
- _join-read-only_: user can join a cluster, but only as a read-only node.
- _remove_: user can remove a node from a cluster.

### Example configuration file
Expand All @@ -59,17 +60,17 @@ An example configuration file is shown below.
{
"username": "mary",
"password": "$2a$10$fKRHxrEuyDTP6tXIiDycr.nyC8Q7UMIfc31YMyXHDLgRDyhLK3VFS",
"perms": ["query", "backup"]
"perms": ["query", "backup", "join"]
},
{
"username": "*",
"perms": ["status", "ready"]
"perms": ["status", "ready", "join-read-only"]
}
]
```
This configuration file sets authentication for three usernames, _bob_, _mary_, and `*`. It sets a password for the first two.

This configuration also sets permissions for all usernames. _bob_ has permission to perform all operations, but _mary_ can only query the cluster, as well as backup the cluster. `*` is a special username, which indicates that all users -- even anonymous users (requests without any BasicAuth information) -- have permission to check the cluster and readiness. This can be useful if you wish to leave certain operations open to all accesses.
This configuration also sets permissions for all usernames. _bob_ has permission to perform all operations, but _mary_ can only query the cluster, as well as backup and join the cluster. `*` is a special username, which indicates that all users -- even anonymous users (requests without any BasicAuth information) -- have permission to check the cluster status and readiness. All users can also join as a read-only node. This can be useful if you wish to leave certain operations open to all accesses.

## Secure cluster example
Starting a node with HTTPS enabled, node-to-node encryption, and with the above configuration file. It is assumed the HTTPS X.509 certificate and key are at the paths `server.crt` and `key.pem` respectively, and the node-to-node certificate and key are at `node.crt` and `node-key.pem`
Expand Down
2 changes: 2 additions & 0 deletions auth/credential_store.go
Expand Up @@ -18,6 +18,8 @@ const (
PermAll = "all"
// PermJoin means user is permitted to join cluster.
PermJoin = "join"
// PermJoinReadOnly means user is permitted to join the cluster only as a read-only node
PermJoinReadOnly = "join-read-only"
// PermRemove means user is permitted to remove a node.
PermRemove = "remove"
// PermExecute means user can access execute endpoint.
Expand Down
7 changes: 6 additions & 1 deletion http/service.go
Expand Up @@ -392,7 +392,7 @@ func (s *Service) RegisterStatus(key string, stat StatusReporter) error {

// handleJoin handles cluster-join requests from other nodes.
func (s *Service) handleJoin(w http.ResponseWriter, r *http.Request) {
if !s.CheckRequestPerm(r, auth.PermJoin) {
if !s.CheckRequestPerm(r, auth.PermJoin) && !s.CheckRequestPerm(r, auth.PermJoinReadOnly) {
w.WriteHeader(http.StatusUnauthorized)
return
}
Expand Down Expand Up @@ -430,6 +430,11 @@ func (s *Service) handleJoin(w http.ResponseWriter, r *http.Request) {
voter = true
}

if voter.(bool) && !s.CheckRequestPerm(r, auth.PermJoin) {
http.Error(w, "joining as voter not allowed", http.StatusUnauthorized)
return
}

if err := s.store.Join(remoteID.(string), remoteAddr.(string), voter.(bool)); err != nil {
if err == store.ErrNotLeader {
leaderAPIAddr := s.LeaderAPIAddr()
Expand Down
89 changes: 89 additions & 0 deletions http/service_test.go
Expand Up @@ -605,6 +605,90 @@ func Test_401Routes_BasicAuthBadPerm(t *testing.T) {
}
}

func Test_401Join(t *testing.T) {
jf := func(_, _, perm string) bool {
return perm == "join" || perm == "join-read-only"
}
c := &mockCredentialStore{aaFunc: jf}

m := &MockStore{}
n := &mockClusterService{}
s := New("127.0.0.1:0", m, n, c)
if err := s.Start(); err != nil {
t.Fatalf("failed to start service")
}
defer s.Close()

client := &http.Client{}
host := fmt.Sprintf("http://%s", s.Addr().String())

resp, err := client.Post(host+"/join", "application/json", strings.NewReader(`{"id": "1", "addr":":4001", "voter": true}`))
if err != nil {
t.Fatalf("failed to make join request")
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("failed to get expected StatusOK for join, got %d", resp.StatusCode)
}

resp, err = client.Post(host+"/join", "application/json", strings.NewReader(`{"id": "1", "addr":":4001"}`))
if err != nil {
t.Fatalf("failed to make join request")
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("failed to get expected StatusOK for join, got %d", resp.StatusCode)
}

resp, err = client.Post(host+"/join", "application/json", strings.NewReader(`{"id": "1", "addr":":4001", "voter": false}`))
if err != nil {
t.Fatalf("failed to make join request")
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("failed to get expected StatusOK for non-voter join, got %d", resp.StatusCode)
}
}

func Test_401JoinReadOnly(t *testing.T) {
jf := func(_, _, perm string) bool {
return perm == "join-read-only"
}
c := &mockCredentialStore{aaFunc: jf}

m := &MockStore{}
n := &mockClusterService{}
s := New("127.0.0.1:0", m, n, c)
if err := s.Start(); err != nil {
t.Fatalf("failed to start service")
}
defer s.Close()

client := &http.Client{}
host := fmt.Sprintf("http://%s", s.Addr().String())

resp, err := client.Post(host+"/join", "application/json", strings.NewReader(`{"id": "1", "addr":":4001", "voter": true}`))
if err != nil {
t.Fatalf("failed to make join request")
}
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("failed to get expected StatusUnauthorized for join, got %d", resp.StatusCode)
}

resp, err = client.Post(host+"/join", "application/json", strings.NewReader(`{"id": "1", "addr":":4001"}`))
if err != nil {
t.Fatalf("failed to make join request")
}
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("failed to get expected StatusUnauthorized for join, got %d", resp.StatusCode)
}

resp, err = client.Post(host+"/join", "application/json", strings.NewReader(`{"id": "1", "addr":":4001", "voter": false}`))
if err != nil {
t.Fatalf("failed to make join request")
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("failed to get expected StatusOK for non-voter join, got %d", resp.StatusCode)
}
}

func Test_BackupOK(t *testing.T) {
m := &MockStore{}
c := &mockClusterService{}
Expand Down Expand Up @@ -1129,12 +1213,17 @@ func (m *mockClusterService) Query(qr *command.QueryRequest, addr string, t time

type mockCredentialStore struct {
HasPermOK bool
aaFunc func(username, password, perm string) bool
}

func (m *mockCredentialStore) AA(username, password, perm string) bool {
if m == nil {
return true
}

if m.aaFunc != nil {
return m.aaFunc(username, password, perm)
}
return m.HasPermOK
}

Expand Down