Some work on permissions (lacks tests)

This commit is contained in:
simon987 2019-02-13 21:54:18 -05:00
parent 4edf354f8d
commit c3e5bd77f7
34 changed files with 650 additions and 273 deletions

View File

@ -145,7 +145,6 @@ func (api *WebAPI) AccountDetails(r *Request) {
logrus.WithFields(logrus.Fields{
"manager": manager,
"session": sess,
}).Trace("Account details request")
if manager == nil {

View File

@ -90,6 +90,8 @@ func New() *WebAPI {
api.router.GET("/project/monitoring-between/:id", LogRequestMiddleware(api.GetSnapshotsBetween))
api.router.GET("/project/monitoring/:id", LogRequestMiddleware(api.GetNSnapshots))
api.router.GET("/project/assignees/:id", LogRequestMiddleware(api.ProjectGetAssigneeStats))
api.router.GET("/project/requests/:id", LogRequestMiddleware(api.ProjectGetAccessRequests))
api.router.GET("/project/request_access/:id", LogRequestMiddleware(api.WorkerRequestAccess))
api.router.POST("/task/create", LogRequestMiddleware(api.TaskCreate))
api.router.GET("/task/get/:project", LogRequestMiddleware(api.TaskGetFromProject))

View File

@ -15,6 +15,7 @@ type CreateProjectRequest struct {
Priority int64 `json:"priority"`
Motd string `json:"motd"`
Public bool `json:"public"`
Hidden bool `json:"hidden"`
}
type UpdateProjectRequest struct {
@ -24,6 +25,7 @@ type UpdateProjectRequest struct {
Priority int64 `json:"priority"`
Motd string `json:"motd"`
Public bool `json:"public"`
Hidden bool `json:"hidden"`
}
type UpdateProjectResponse struct {
@ -55,8 +57,22 @@ type GetAssigneeStatsResponse struct {
Assignees *[]storage.AssignedTasks `json:"assignees"`
}
type WorkerRequestAccessResponse struct {
Ok bool `json:"ok"`
Message string `json:"message,omitempty"`
}
type ProjectGetAccessRequestsResponse struct {
Ok bool `json:"ok"`
Message string `json:"message,omitempty"`
Requests *[]storage.Worker `json:"requests,omitempty"`
}
func (api *WebAPI) ProjectCreate(r *Request) {
sess := api.Session.StartFasthttp(r.Ctx)
manager := sess.Get("manager")
createReq := &CreateProjectRequest{}
err := json.Unmarshal(r.Ctx.Request.Body(), createReq)
if err != nil {
@ -74,26 +90,10 @@ func (api *WebAPI) ProjectCreate(r *Request) {
Priority: createReq.Priority,
Motd: createReq.Motd,
Public: createReq.Public,
Hidden: createReq.Hidden,
}
if isValidProject(project) {
id, err := api.Database.SaveProject(project)
if err != nil {
r.Json(CreateProjectResponse{
Ok: false,
Message: err.Error(),
}, 500)
} else {
r.OkJson(CreateProjectResponse{
Ok: true,
Id: id,
})
logrus.WithFields(logrus.Fields{
"project": project,
}).Debug("Created project")
}
} else {
if !isValidProject(project) {
logrus.WithFields(logrus.Fields{
"project": project,
}).Warn("Invalid project")
@ -102,7 +102,36 @@ func (api *WebAPI) ProjectCreate(r *Request) {
Ok: false,
Message: "Invalid project",
}, 400)
return
}
if !isProjectCreationAuthorized(project, manager) {
logrus.WithFields(logrus.Fields{
"project": project,
}).Warn("Unauthorized project creation")
r.Json(CreateProjectResponse{
Ok: false,
Message: "You are not permitted to create a project with this configuration",
}, 400)
return
}
id, err := api.Database.SaveProject(project)
if err != nil {
r.Json(CreateProjectResponse{
Ok: false,
Message: err.Error(),
}, 500)
return
}
r.OkJson(CreateProjectResponse{
Ok: true,
Id: id,
})
logrus.WithFields(logrus.Fields{
"project": project,
}).Debug("Created project")
}
func (api *WebAPI) ProjectUpdate(r *Request) {
@ -133,6 +162,7 @@ func (api *WebAPI) ProjectUpdate(r *Request) {
Priority: updateReq.Priority,
Motd: updateReq.Motd,
Public: updateReq.Public,
Hidden: updateReq.Hidden,
}
if isValidProject(project) {
@ -180,6 +210,38 @@ func isValidProject(project *storage.Project) bool {
return true
}
func isProjectCreationAuthorized(project *storage.Project, manager interface{}) bool {
return true
if manager == nil {
return false
}
if project.Public && manager.(*storage.Manager).WebsiteAdmin {
return false
}
return true
}
func isProjectUpdateAuthorized(project *storage.Project, manager interface{}, db *storage.Database) bool {
var man storage.Manager
if manager != nil {
man = manager.(storage.Manager)
}
if man.WebsiteAdmin {
return true
}
role := db.ManagerHasRoleOn(&man, project.Id)
if role&storage.ROLE_EDIT == 1 {
return true
}
return false
}
func (api *WebAPI) ProjectGet(r *Request) {
id, err := strconv.ParseInt(r.Ctx.UserValue("id").(string), 10, 64)
@ -230,3 +292,64 @@ func (api *WebAPI) ProjectGetAssigneeStats(r *Request) {
Assignees: stats,
})
}
func (api *WebAPI) ProjectGetAccessRequests(r *Request) {
sess := api.Session.StartFasthttp(r.Ctx)
manager := sess.Get("manager")
id, err := strconv.ParseInt(r.Ctx.UserValue("id").(string), 10, 64)
handleErr(err, r) //todo handle invalid id
if manager == nil {
r.Json(ProjectGetAccessRequestsResponse{
Ok: false,
Message: "Unauthorized",
}, 401)
return
}
if !manager.(*storage.Manager).WebsiteAdmin &&
api.Database.ManagerHasRoleOn(manager.(*storage.Manager), 1)&
storage.ROLE_MANAGE_ACCESS == 0 {
r.Json(ProjectGetAccessRequestsResponse{
Ok: false,
Message: "Unauthorized",
}, 403)
return
}
requests := api.Database.GetAllAccessRequests(id)
r.OkJson(ProjectGetAccessRequestsResponse{
Ok: true,
Requests: requests,
})
}
func (api *WebAPI) WorkerRequestAccess(r *Request) {
id, err := strconv.ParseInt(r.Ctx.UserValue("id").(string), 10, 64)
handleErr(err, r) //todo handle invalid id
worker, err := api.validateSignature(r)
if err != nil {
r.Json(WorkerRequestAccessResponse{
Ok: false,
Message: err.Error(),
}, 401)
}
res := api.Database.SaveAccessRequest(worker, id)
if res {
r.OkJson(WorkerRequestAccessResponse{
Ok: true,
})
} else {
r.Json(WorkerRequestAccessResponse{
Ok: false,
Message: "Project is public, you already have " +
"an active request or you already have access to this project",
}, 400)
}
}

View File

@ -177,6 +177,10 @@ func (api WebAPI) validateSignature(r *Request) (*storage.Worker, error) {
widStr := string(r.Ctx.Request.Header.Peek("X-Worker-Id"))
signature := r.Ctx.Request.Header.Peek("X-Signature")
if widStr == "" {
return nil, errors.New("worker id not specified")
}
wid, err := strconv.ParseInt(widStr, 10, 64)
if err != nil {
logrus.WithError(err).WithFields(logrus.Fields{

View File

@ -58,11 +58,9 @@ func (api *WebAPI) WorkerCreate(r *Request) {
return
}
identity := getIdentity(r)
if !canCreateWorker(r, workerReq, identity) {
if !canCreateWorker(r, workerReq) {
logrus.WithFields(logrus.Fields{
"identity": identity,
"createWorkerRequest": workerReq,
}).Warn("Failed CreateWorkerRequest")
@ -73,7 +71,7 @@ func (api *WebAPI) WorkerCreate(r *Request) {
return
}
worker, err := api.workerCreate(workerReq, getIdentity(r))
worker, err := api.workerCreate(workerReq)
if err != nil {
handleErr(err, r)
} else {
@ -185,7 +183,7 @@ func (api *WebAPI) WorkerUpdate(r *Request) {
r.Json(GetTaskResponse{
Ok: false,
Message: err.Error(),
}, 403)
}, 401)
return
}
@ -224,24 +222,23 @@ func (api *WebAPI) GetAllWorkerStats(r *Request) {
})
}
func (api *WebAPI) workerCreate(request *CreateWorkerRequest, identity *storage.Identity) (*storage.Worker, error) {
func (api *WebAPI) workerCreate(request *CreateWorkerRequest) (*storage.Worker, error) {
if request.Alias == "" {
request.Alias = "default_alias"
}
worker := storage.Worker{
Created: time.Now().Unix(),
Identity: identity,
Secret: makeSecret(),
Alias: request.Alias,
Created: time.Now().Unix(),
Secret: makeSecret(),
Alias: request.Alias,
}
api.Database.SaveWorker(&worker)
return &worker, nil
}
func canCreateWorker(r *Request, cwr *CreateWorkerRequest, identity *storage.Identity) bool {
func canCreateWorker(r *Request, cwr *CreateWorkerRequest) bool {
if cwr.Alias == "unassigned" {
//Reserved alias
@ -260,13 +257,3 @@ func makeSecret() []byte {
return secret
}
func getIdentity(r *Request) *storage.Identity {
identity := storage.Identity{
RemoteAddr: r.Ctx.RemoteAddr().String(),
UserAgent: string(r.Ctx.UserAgent()),
}
return &identity
}

View File

@ -1,39 +1,30 @@
DROP TABLE IF EXISTS worker_identity, worker, project, task, log_entry,
DROP TABLE IF EXISTS worker, project, task, log_entry,
worker_has_access_to_project, manager, manager_has_role_on_project, project_monitoring_snapshot,
worker_verifies_task;
worker_verifies_task, worker_requests_access_to_project;
DROP TYPE IF EXISTS status;
DROP TYPE IF EXISTS log_level;
CREATE TABLE worker_identity
(
id SERIAL PRIMARY KEY,
remote_addr TEXT,
user_agent TEXT,
UNIQUE (remote_addr)
);
CREATE TABLE worker
(
id SERIAL PRIMARY KEY,
alias TEXT,
created INTEGER,
identity INTEGER REFERENCES worker_identity (id),
secret BYTEA,
closed_task_count INTEGER DEFAULT 0
id SERIAL PRIMARY KEY NOT NULL,
alias TEXT NOT NULL,
created INTEGER NOT NULL,
secret BYTEA NOT NULL,
closed_task_count INTEGER DEFAULT 0 NOT NULL
);
CREATE TABLE project
(
id SERIAL PRIMARY KEY,
priority INTEGER DEFAULT 0,
name TEXT UNIQUE,
clone_url TEXT,
git_repo TEXT UNIQUE,
version TEXT,
motd TEXT,
public boolean,
closed_task_count INT DEFAULT 0
id SERIAL PRIMARY KEY NOT NULL,
priority INTEGER DEFAULT 0 NOT NULL,
closed_task_count INT DEFAULT 0 NOT NULL,
public boolean NOT NULL,
hidden boolean NOT NULL,
name TEXT UNIQUE NOT NULL,
clone_url TEXT NOT NULL,
git_repo TEXT UNIQUE NOT NULL,
version TEXT NOT NULL,
motd TEXT NOT NULL
);
CREATE TABLE worker_has_access_to_project
@ -61,43 +52,51 @@ CREATE TABLE task
CREATE TABLE worker_verifies_task
(
verification_hash BIGINT,
task BIGINT REFERENCES task (id) ON DELETE CASCADE,
worker INT REFERENCES worker (id)
verification_hash BIGINT NOT NULL,
task BIGINT REFERENCES task (id) ON DELETE CASCADE NOT NULL,
worker INT REFERENCES worker (id) NOT NULL
);
CREATE TABLE log_entry
(
level INTEGER,
message TEXT,
message_data TEXT,
timestamp INTEGER
level INTEGER NOT NULL,
message TEXT NOT NULL,
message_data TEXT NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE TABLE manager
(
id SERIAL PRIMARY KEY,
username TEXT UNIQUE,
password BYTEA,
website_admin BOOLEAN
register_time INTEGER NOT NULL,
website_admin BOOLEAN NOT NULL,
username TEXT UNIQUE NOT NULL,
password BYTEA NOT NULL
);
CREATE TABLE manager_has_role_on_project
(
manager INTEGER REFERENCES manager (id),
role SMALLINT,
project INTEGER REFERENCES project (id)
manager INTEGER REFERENCES manager (id) NOT NULL,
role SMALLINT NOT NULl,
project INTEGER REFERENCES project (id) NOT NULL
);
CREATE TABLE project_monitoring_snapshot
(
project INT REFERENCES project (id),
new_task_count INT,
failed_task_count INT,
closed_task_count INT,
awaiting_verification_task_count INT,
worker_access_count INT,
timestamp INT
project INT REFERENCES project (id) NOT NULL,
new_task_count INT NOT NULL,
failed_task_count INT NOT NULL,
closed_task_count INT NOT NULL,
awaiting_verification_task_count INT NOT NULL,
worker_access_count INT NOT NULL,
timestamp INT NOT NULL
);
CREATE TABLE worker_requests_access_to_project
(
worker INT REFERENCES worker (id),
project INT REFERENCES project (id),
PRIMARY KEY (worker, project)
);
CREATE OR REPLACE FUNCTION on_task_delete_proc() RETURNS TRIGGER AS
@ -114,6 +113,21 @@ CREATE TRIGGER on_task_delete
FOR EACH ROW
EXECUTE PROCEDURE on_task_delete_proc();
CREATE OR REPLACE FUNCTION on_manager_insert() RETURNS TRIGGER AS
$$
BEGIN
IF NEW.id = 1 THEN
UPDATE manager SET website_admin= TRUE WHERE id = 1;
end if;
RETURN NEW;
END;
$$ LANGUAGE 'plpgsql';
CREATE TRIGGER on_manager_insert
AFTER INSERT
ON manager
FOR EACH ROW
EXECUTE PROCEDURE on_manager_insert();
CREATE OR REPLACE FUNCTION release_task_ok(wid INT, tid INT, ver INT) RETURNS BOOLEAN AS
$$
DECLARE

View File

@ -10,9 +10,10 @@ import (
type ManagerRole int
const (
ROLE_NONE ManagerRole = 0
ROLE_READ ManagerRole = 1
ROLE_EDIT ManagerRole = 2
ROLE_MANAGE_ACCESS ManagerRole = 3
ROLE_MANAGE_ACCESS ManagerRole = 4
)
type Manager struct {
@ -65,8 +66,8 @@ func (database *Database) SaveManager(manager *Manager, password []byte) error {
hash.Write([]byte(manager.Username))
hashedPassword := hash.Sum(nil)
row := db.QueryRow(`INSERT INTO manager (username, password, website_admin)
VALUES ($1,$2,$3) RETURNING ID`,
row := db.QueryRow(`INSERT INTO manager (username, password, website_admin, register_time)
VALUES ($1,$2,$3, extract(epoch from now() at time zone 'utc')) RETURNING ID`,
manager.Username, hashedPassword, manager.WebsiteAdmin)
err := row.Scan(&manager.Id)
@ -78,6 +79,8 @@ func (database *Database) SaveManager(manager *Manager, password []byte) error {
return err
}
manager.WebsiteAdmin = manager.Id == 1
logrus.WithFields(logrus.Fields{
"manager": manager,
}).Info("Database.SaveManager INSERT")
@ -121,3 +124,19 @@ func (database *Database) UpdateManagerPassword(manager *Manager, newPassword []
"id": manager.Id,
}).Warning("Database.UpdateManagerPassword UPDATE")
}
func (database *Database) ManagerHasRoleOn(manager *Manager, projectId int64) ManagerRole {
db := database.getDB()
row := db.QueryRow(`SELECT role FROM manager_has_role_on_project
WHERE project=$1 AND manager=$2`, projectId, manager.Id)
var role ManagerRole
err := row.Scan(&role)
if err != nil {
return ROLE_NONE
}
return role
}

View File

@ -47,7 +47,7 @@ func (database *Database) MakeProjectSnapshots() {
"took": time.Now().Sub(startTime),
"add": inserted,
"remove": deleted,
}).Trace("Took monitoring snapshot")
}).Trace("Took project monitoring snapshot")
}
func (database *Database) GetMonitoringSnapshotsBetween(pid int64, from int, to int) (ss *[]ProjectMonitoringSnapshot) {

View File

@ -15,6 +15,7 @@ type Project struct {
Version string `json:"version"`
Motd string `json:"motd"`
Public bool `json:"public"`
Hidden bool `json:"hidden"`
}
type AssignedTasks struct {
@ -31,9 +32,10 @@ func (database *Database) SaveProject(project *Project) (int64, error) {
func saveProject(project *Project, db *sql.DB) (int64, error) {
row := db.QueryRow(`INSERT INTO project (name, git_repo, clone_url, version, priority, motd, public)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
project.Name, project.GitRepo, project.CloneUrl, project.Version, project.Priority, project.Motd, project.Public)
row := db.QueryRow(`INSERT INTO project (name, git_repo, clone_url, version, priority, motd, public, hidden)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
project.Name, project.GitRepo, project.CloneUrl, project.Version, project.Priority, project.Motd,
project.Public, project.Hidden)
var id int64
err := row.Scan(&id)
@ -64,7 +66,7 @@ func (database *Database) GetProject(id int64) *Project {
func getProject(id int64, db *sql.DB) *Project {
row := db.QueryRow(`SELECT id, priority, name, clone_url, git_repo, version, motd, public
row := db.QueryRow(`SELECT id, priority, name, clone_url, git_repo, version, motd, public, hidden
FROM project WHERE id=$1`, id)
project, err := scanProject(row)
@ -87,7 +89,7 @@ func scanProject(row *sql.Row) (*Project, error) {
project := &Project{}
err := row.Scan(&project.Id, &project.Priority, &project.Name, &project.CloneUrl,
&project.GitRepo, &project.Version, &project.Motd, &project.Public)
&project.GitRepo, &project.Version, &project.Motd, &project.Public, &project.Hidden)
return project, err
}
@ -95,7 +97,7 @@ func scanProject(row *sql.Row) (*Project, error) {
func (database *Database) GetProjectWithRepoName(repoName string) *Project {
db := database.getDB()
row := db.QueryRow(`SELECT id, priority, name, clone_url, git_repo, version, motd, public
row := db.QueryRow(`SELECT id, priority, name, clone_url, git_repo, version, motd, public, hidden
FROM project WHERE LOWER(git_repo)=$1`,
strings.ToLower(repoName))
@ -115,8 +117,9 @@ func (database *Database) UpdateProject(project *Project) error {
db := database.getDB()
res, err := db.Exec(`UPDATE project
SET (priority, name, clone_url, git_repo, version, motd, public) = ($1,$2,$3,$4,$5,$6,$7) WHERE id=$8`,
project.Priority, project.Name, project.CloneUrl, project.GitRepo, project.Version, project.Motd, project.Public, project.Id)
SET (priority, name, clone_url, git_repo, version, motd, public, hidden) = ($1,$2,$3,$4,$5,$6,$7,$8) WHERE id=$9`,
project.Priority, project.Name, project.CloneUrl, project.GitRepo, project.Version, project.Motd,
project.Public, project.Hidden, project.Id)
if err != nil {
return err
}
@ -139,26 +142,24 @@ func (database Database) GetAllProjects(workerId int64) *[]Project {
var err error
if workerId == 0 {
rows, err = db.Query(`SELECT
Id, priority, name, clone_url, git_repo, version, motd, public
Id, priority, name, clone_url, git_repo, version, motd, public, hidden
FROM project
LEFT JOIN worker_has_access_to_project whatp ON whatp.project = id
WHERE public
WHERE NOT hidden
ORDER BY name`)
} else {
rows, err = db.Query(`SELECT
Id, priority, name, clone_url, git_repo, version, motd, public
Id, priority, name, clone_url, git_repo, version, motd, public, hidden
FROM project
LEFT JOIN worker_has_access_to_project whatp ON whatp.project = id
WHERE public OR whatp.worker = $1
WHERE NOT hidden OR whatp.worker = $1
ORDER BY name`, workerId)
}
handleErr(err)
for rows.Next() {
p := Project{}
err := rows.Scan(&p.Id, &p.Priority, &p.Name, &p.CloneUrl,
&p.GitRepo, &p.Version, &p.Motd, &p.Public)
&p.GitRepo, &p.Version, &p.Motd, &p.Public, &p.Hidden)
handleErr(err)
projects = append(projects, p)
}

View File

@ -1,22 +1,14 @@
package storage
import (
"database/sql"
"errors"
"github.com/Sirupsen/logrus"
)
type Identity struct {
RemoteAddr string `json:"remote_addr"`
UserAgent string `json:"user_agent"`
}
type Worker struct {
Id int64 `json:"id"`
Created int64 `json:"created"`
Identity *Identity `json:"identity"`
Alias string `json:"alias,omitempty"`
Secret []byte `json:"secret"`
Id int64 `json:"id"`
Created int64 `json:"created"`
Alias string `json:"alias,omitempty"`
Secret []byte `json:"secret"`
}
type WorkerStats struct {
@ -28,10 +20,8 @@ func (database *Database) SaveWorker(worker *Worker) {
db := database.getDB()
identityId := getOrCreateIdentity(worker.Identity, db)
row := db.QueryRow("INSERT INTO worker (created, identity, secret, alias) VALUES ($1,$2,$3,$4) RETURNING id",
worker.Created, identityId, worker.Secret, worker.Alias)
row := db.QueryRow("INSERT INTO worker (created, secret, alias) VALUES ($1,$2,$3) RETURNING id",
worker.Created, worker.Secret, worker.Alias)
err := row.Scan(&worker.Id)
handleErr(err)
@ -46,10 +36,9 @@ func (database *Database) GetWorker(id int64) *Worker {
db := database.getDB()
worker := &Worker{}
var identityId int64
row := db.QueryRow("SELECT id, created, identity, secret, alias FROM worker WHERE id=$1", id)
err := row.Scan(&worker.Id, &worker.Created, &identityId, &worker.Secret, &worker.Alias)
row := db.QueryRow("SELECT id, created, secret, alias FROM worker WHERE id=$1", id)
err := row.Scan(&worker.Id, &worker.Created, &worker.Secret, &worker.Alias)
if err != nil {
logrus.WithFields(logrus.Fields{
"id": id,
@ -57,9 +46,6 @@ func (database *Database) GetWorker(id int64) *Worker {
return nil
}
worker.Identity, err = getIdentity(identityId, db)
handleErr(err)
logrus.WithFields(logrus.Fields{
"worker": worker,
}).Trace("Database.getWorker SELECT worker")
@ -67,48 +53,6 @@ func (database *Database) GetWorker(id int64) *Worker {
return worker
}
func getIdentity(id int64, db *sql.DB) (*Identity, error) {
identity := &Identity{}
row := db.QueryRow("SELECT remote_addr, user_agent FROM worker_identity WHERE id=$1", id)
err := row.Scan(&identity.RemoteAddr, &identity.UserAgent)
if err != nil {
return nil, errors.New("identity not found")
}
logrus.WithFields(logrus.Fields{
"identity": identity,
}).Trace("Database.getIdentity SELECT workerIdentity")
return identity, nil
}
func getOrCreateIdentity(identity *Identity, db *sql.DB) int64 {
res, err := db.Exec("INSERT INTO worker_identity (remote_addr, user_agent) VALUES ($1,$2) ON CONFLICT DO NOTHING",
identity.RemoteAddr, identity.UserAgent)
handleErr(err)
rowsAffected, err := res.RowsAffected()
logrus.WithFields(logrus.Fields{
"rowsAffected": rowsAffected,
}).Trace("Database.saveWorker INSERT workerIdentity")
row := db.QueryRow("SELECT (id) FROM worker_identity WHERE remote_addr=$1", identity.RemoteAddr)
var rowId int64
err = row.Scan(&rowId)
handleErr(err)
logrus.WithFields(logrus.Fields{
"rowId": rowId,
}).Trace("Database.saveWorker SELECT workerIdentity")
return rowId
}
func (database *Database) GrantAccess(workerId int64, projectId int64) bool {
db := database.getDB()
@ -169,6 +113,88 @@ func (database *Database) UpdateWorker(worker *Worker) bool {
return rowsAffected == 1
}
func (database *Database) SaveAccessRequest(worker *Worker, projectId int64) bool {
db := database.getDB()
res, err := db.Exec(`INSERT INTO worker_requests_access_to_project
SELECT $1, id FROM project WHERE id=$2 AND NOT project.public
AND NOT EXISTS(SELECT * FROM worker_has_access_to_project WHERE worker=$1 AND project=$2)`,
worker.Id, projectId)
if err != nil {
return false
}
rowsAffected, _ := res.RowsAffected()
logrus.WithFields(logrus.Fields{
"rowsAffected": rowsAffected,
}).Trace("Database.SaveAccessRequest INSERT")
return rowsAffected == 1
}
func (database *Database) AcceptAccessRequest(worker *Worker, projectId int64) bool {
db := database.getDB()
res, err := db.Exec(`DELETE FROM worker_requests_access_to_project
WHERE worker=$1 AND project=$2`)
handleErr(err)
rowsAffected, _ := res.RowsAffected()
if rowsAffected == 1 {
_, err := db.Exec(`INSERT INTO worker_has_access_to_project
(worker, project) VALUES ($1,$2)`,
worker.Id, projectId)
handleErr(err)
}
logrus.WithFields(logrus.Fields{
"rowsAffected": rowsAffected,
}).Trace("Database.AcceptAccessRequest")
return rowsAffected == 1
}
func (database *Database) RejectAccessRequest(worker *Worker, projectId int64) bool {
db := database.getDB()
res, err := db.Exec(`DELETE FROM worker_requests_access_to_project
WHERE worker=$1 AND project=$2`, worker.Id, projectId)
handleErr(err)
rowsAffected, _ := res.RowsAffected()
logrus.WithFields(logrus.Fields{
"rowsAffected": rowsAffected,
}).Trace("Database.AcceptAccessRequest")
return rowsAffected == 1
}
func (database *Database) GetAllAccessRequests(projectId int64) *[]Worker {
db := database.getDB()
rows, err := db.Query(`SELECT id, alias, created FROM worker_requests_access_to_project
INNER JOIN worker w on worker_requests_access_to_project.worker = w.id
WHERE project=$1`,
projectId)
handleErr(err)
requests := make([]Worker, 0)
for rows.Next() {
w := Worker{}
_ = rows.Scan(&w.Id, &w.Alias, &w.Created)
requests = append(requests, w)
}
return &requests
}
func (database *Database) GetAllWorkerStats() *[]WorkerStats {
db := database.getDB()

View File

@ -163,3 +163,7 @@ func login(request *api.LoginRequest) (*api.LoginResponse, *http.Response) {
return resp, r
}
func getSessionCtx(username string, password string, admin bool) {
}

View File

@ -19,6 +19,7 @@ func TestCreateGetProject(t *testing.T) {
Priority: 123,
Motd: "motd",
Public: true,
Hidden: true,
})
id := resp.Id
@ -59,6 +60,9 @@ func TestCreateGetProject(t *testing.T) {
if getResp.Project.Public != true {
t.Error()
}
if getResp.Project.Hidden != true {
t.Error()
}
}
func TestCreateProjectInvalid(t *testing.T) {
@ -141,6 +145,7 @@ func TestUpdateProjectValid(t *testing.T) {
Name: "NameB",
Motd: "MotdB",
Public: false,
Hidden: true,
}, pid)
if updateResp.Ok != true {
@ -164,6 +169,9 @@ func TestUpdateProjectValid(t *testing.T) {
if proj.Project.Priority != 2 {
t.Error()
}
if proj.Project.Hidden != true {
t.Error()
}
}
func TestUpdateProjectInvalid(t *testing.T) {

View File

@ -33,12 +33,6 @@ func TestCreateGetWorker(t *testing.T) {
t.Error()
}
if len(getResp.Worker.Identity.RemoteAddr) <= 0 {
t.Error()
}
if len(getResp.Worker.Identity.UserAgent) <= 0 {
t.Error()
}
if resp.Worker.Alias != "my_worker_alias" {
t.Error()
}

View File

@ -15,6 +15,11 @@ import (
"strconv"
)
type SessionContext struct {
Manager *storage.Manager
SessionCookie *http.Cookie
}
func Post(path string, x interface{}, worker *storage.Worker) *http.Response {
body, err := json.Marshal(x)

View File

@ -1,39 +1,30 @@
DROP TABLE IF EXISTS worker_identity, worker, project, task, log_entry,
DROP TABLE IF EXISTS worker, project, task, log_entry,
worker_has_access_to_project, manager, manager_has_role_on_project, project_monitoring_snapshot,
worker_verifies_task;
worker_verifies_task, worker_requests_access_to_project;
DROP TYPE IF EXISTS status;
DROP TYPE IF EXISTS log_level;
CREATE TABLE worker_identity
(
id SERIAL PRIMARY KEY,
remote_addr TEXT,
user_agent TEXT,
UNIQUE (remote_addr)
);
CREATE TABLE worker
(
id SERIAL PRIMARY KEY,
alias TEXT,
created INTEGER,
identity INTEGER REFERENCES worker_identity (id),
secret BYTEA,
closed_task_count INTEGER DEFAULT 0
id SERIAL PRIMARY KEY NOT NULL,
alias TEXT NOT NULL,
created INTEGER NOT NULL,
secret BYTEA NOT NULL,
closed_task_count INTEGER DEFAULT 0 NOT NULL
);
CREATE TABLE project
(
id SERIAL PRIMARY KEY,
priority INTEGER DEFAULT 0,
name TEXT UNIQUE,
clone_url TEXT,
git_repo TEXT UNIQUE,
version TEXT,
motd TEXT,
public boolean,
closed_task_count INT DEFAULT 0
id SERIAL PRIMARY KEY NOT NULL,
priority INTEGER DEFAULT 0 NOT NULL,
closed_task_count INT DEFAULT 0 NOT NULL,
public boolean NOT NULL,
hidden boolean NOT NULL,
name TEXT UNIQUE NOT NULL,
clone_url TEXT NOT NULL,
git_repo TEXT UNIQUE NOT NULL,
version TEXT NOT NULL,
motd TEXT NOT NULL
);
CREATE TABLE worker_has_access_to_project
@ -61,43 +52,50 @@ CREATE TABLE task
CREATE TABLE worker_verifies_task
(
verification_hash BIGINT,
task BIGINT REFERENCES task (id) ON DELETE CASCADE,
worker INT REFERENCES worker (id)
verification_hash BIGINT NOT NULL,
task BIGINT REFERENCES task (id) ON DELETE CASCADE NOT NULL,
worker INT REFERENCES worker (id) NOT NULL
);
CREATE TABLE log_entry
(
level INTEGER,
message TEXT,
message_data TEXT,
timestamp INTEGER
level INTEGER NOT NULL,
message TEXT NOT NULL,
message_data TEXT NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE TABLE manager
(
id SERIAL PRIMARY KEY,
username TEXT UNIQUE,
password BYTEA,
website_admin BOOLEAN
register_time INTEGER NOT NULL,
website_admin BOOLEAN NOT NULL,
username TEXT UNIQUE NOT NULL,
password BYTEA NOT NULL
);
CREATE TABLE manager_has_role_on_project
(
manager INTEGER REFERENCES manager (id),
role SMALLINT,
project INTEGER REFERENCES project (id)
manager INTEGER REFERENCES manager (id) NOT NULL,
role SMALLINT NOT NULl,
project INTEGER REFERENCES project (id) NOT NULL
);
CREATE TABLE project_monitoring_snapshot
(
project INT REFERENCES project (id),
new_task_count INT,
failed_task_count INT,
closed_task_count INT,
awaiting_verification_task_count INT,
worker_access_count INT,
timestamp INT
project INT REFERENCES project (id) NOT NULL,
new_task_count INT NOT NULL,
failed_task_count INT NOT NULL,
closed_task_count INT NOT NULL,
awaiting_verification_task_count INT NOT NULL,
worker_access_count INT NOT NULL,
timestamp INT NOT NULL
);
CREATE TABLE worker_requests_access_to_project
(
worker INT REFERENCES worker (id) NOT NULL,
project INT REFERENCES project (id) NOT NULL
);
CREATE OR REPLACE FUNCTION on_task_delete_proc() RETURNS TRIGGER AS
@ -114,6 +112,21 @@ CREATE TRIGGER on_task_delete
FOR EACH ROW
EXECUTE PROCEDURE on_task_delete_proc();
CREATE OR REPLACE FUNCTION on_manager_insert() RETURNS TRIGGER AS
$$
BEGIN
IF NEW.id = 1 THEN
UPDATE manager SET website_admin= TRUE WHERE id = 1;
end if;
RETURN NEW;
END;
$$ LANGUAGE 'plpgsql';
CREATE TRIGGER on_manager_insert
AFTER INSERT
ON manager
FOR EACH ROW
EXECUTE PROCEDURE on_manager_insert();
CREATE OR REPLACE FUNCTION release_task_ok(wid INT, tid INT, ver INT) RETURNS BOOLEAN AS
$$
DECLARE

View File

@ -65,4 +65,8 @@ export class ApiService {
return this.http.get(this.url + `/worker/stats`, this.options)
}
getProjectAccessRequests(project: number) {
return this.http.get(this.url + `/project/requests/${project}`)
}
}

View File

@ -11,6 +11,7 @@ import {TranslateService} from "@ngx-translate/core";
import {LoginComponent} from "./login/login.component";
import {AccountDetailsComponent} from "./account-details/account-details.component";
import {WorkerDashboardComponent} from "./worker-dashboard/worker-dashboard.component";
import {ProjectPermsComponent} from "./project-perms/project-perms.component";
const routes: Routes = [
{path: "log", component: LogsComponent},
@ -19,6 +20,7 @@ const routes: Routes = [
{path: "projects", component: ProjectListComponent},
{path: "project/:id", component: ProjectDashboardComponent},
{path: "project/:id/update", component: UpdateProjectComponent},
{path: "project/:id/perms", component: ProjectPermsComponent},
{path: "new_project", component: CreateProjectComponent},
{path: "workers", component: WorkerDashboardComponent}
];

View File

@ -6,10 +6,11 @@
[routerLink]="'log'">{{"nav.logs" | translate}}</button>
<button mat-button [class.mat-accent]="router.url == '/projects'" class="nav-link"
[routerLink]="'projects'">{{"nav.project_list" | translate}}</button>
<button mat-button [class.mat-accent]="router.url == '/new_project'" class="nav-link"
[routerLink]="'new_project'">{{"nav.new_project" | translate}}</button>
<button mat-button [class.mat-accent]="router.url == '/workers'" class="nav-link"
[routerLink]="'workers'">{{"nav.worker_dashboard" | translate}}</button>
<button mat-button [class.mat-accent]="router.url == '/new_project'" class="nav-link"
[routerLink]="'new_project'"
*ngIf="authService.logged">{{"nav.new_project" | translate}}</button>
</div>
<div class="small-nav">
<button mat-button [matMenuTriggerFor]="smallNav">
@ -22,10 +23,11 @@
[routerLink]="'log'">{{"nav.logs" | translate}}</button>
<button mat-menu-item [class.mat-accent]="router.url == '/projects'" class="nav-link"
[routerLink]="'projects'">{{"nav.project_list" | translate}}</button>
<button mat-menu-item [class.mat-accent]="router.url == '/new_project'" class="nav-link"
[routerLink]="'new_project'">{{"nav.new_project" | translate}}</button>
<button mat-menu-item [class.mat-accent]="router.url == '/workers'" class="nav-link"
[routerLink]="'workers'">{{"nav.worker_dashboard" | translate}}</button>
<button mat-menu-item [class.mat-accent]="router.url == '/new_project'" class="nav-link"
[routerLink]="'new_project'"
*ngIf="authService.logged">{{"nav.new_project" | translate}}</button>
</mat-menu>
</div>
<span class="nav-spacer"></span>

View File

@ -20,7 +20,7 @@ export class AppComponent {
];
constructor(private translate: TranslateService,
private router: Router,
public router: Router,
public authService: AuthService) {
translate.addLangs([

View File

@ -47,6 +47,7 @@ import {TranslatedPaginator} from "./TranslatedPaginatorConfiguration";
import {LoginComponent} from './login/login.component';
import {AccountDetailsComponent} from './account-details/account-details.component';
import {WorkerDashboardComponent} from './worker-dashboard/worker-dashboard.component';
import {ProjectPermsComponent} from './project-perms/project-perms.component';
export function createTranslateLoader(http: HttpClient) {
@ -66,6 +67,7 @@ export function createTranslateLoader(http: HttpClient) {
LoginComponent,
AccountDetailsComponent,
WorkerDashboardComponent,
ProjectPermsComponent,
],
imports: [
BrowserModule,

View File

@ -10,6 +10,7 @@ import {Router} from "@angular/router";
export class AuthService {
account: Manager;
logged: boolean;
constructor(private apiService: ApiService,
private messengerService: MessengerService,
@ -17,6 +18,7 @@ export class AuthService {
this.apiService.getAccountDetails()
.subscribe((data: any) => {
this.account = data.manager;
this.logged = data.logged_in;
})
}
@ -27,6 +29,7 @@ export class AuthService {
this.apiService.getAccountDetails()
.subscribe((data: any) => {
this.account = data.manager;
this.logged = true;
this.router.navigateByUrl("/account");
})
},
@ -42,7 +45,8 @@ export class AuthService {
.subscribe(
() => {
this.account = null;
this.router.navigateByUrl("");
this.logged = false;
this.router.navigateByUrl("login");
},
error => {
console.log(error);

View File

@ -3,40 +3,47 @@
<mat-tab-group>
<mat-tab [label]="'login.title' | translate" class="pad">
<mat-form-field appearance="outline">
<mat-label>{{"login.username" | translate}}</mat-label>
<input type="text" matInput [(ngModel)]="credentials.username">
</mat-form-field>
<form (ngSubmit)="login()">
<mat-form-field appearance="outline">
<mat-label>{{"login.username" | translate}}</mat-label>
<input type="text" matInput [(ngModel)]="credentials.username"
[ngModelOptions]="{standalone: true}">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ "login.password" | translate}}</mat-label>
<input type="password" matInput [(ngModel)]="credentials.password">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ "login.password" | translate}}</mat-label>
<input type="password" matInput [(ngModel)]="credentials.password"
[ngModelOptions]="{standalone: true}">
</mat-form-field>
<button mat-raised-button color="primary"
(click)="login()">{{"login.login" | translate}}</button>
<button mat-raised-button color="primary"
type="submit">{{"login.login" | translate}}</button>
</form>
</mat-tab>
<mat-tab [label]="'create_account.title' | translate" class="pad">
<mat-form-field appearance="outline" [hideRequiredMarker]="true">
<mat-label>{{"login.username" | translate}}</mat-label>
<mat-hint align="end">{{credentials.username?.length || 0}}/16</mat-hint>
<input maxlength="16" type="text" matInput [(ngModel)]="credentials.username" name="username"
required>
</mat-form-field>
<form (ngSubmit)="register()">
<mat-form-field appearance="outline" [hideRequiredMarker]="true">
<mat-label>{{"login.username" | translate}}</mat-label>
<mat-hint align="end">{{credentials.username?.length || 0}}/16</mat-hint>
<input maxlength="16" type="text" matInput [(ngModel)]="credentials.username" name="username"
[ngModelOptions]="{standalone: true}" required>
</mat-form-field>
<mat-form-field appearance="outline" [hideRequiredMarker]="true">
<mat-label>{{ "login.password" | translate}}</mat-label>
<input type="password" matInput [(ngModel)]="credentials.password" name="password" required>
</mat-form-field>
<mat-form-field appearance="outline" [hideRequiredMarker]="true">
<mat-label>{{ "login.password" | translate}}</mat-label>
<input type="password" matInput [(ngModel)]="credentials.password" name="password"
[ngModelOptions]="{standalone: true}">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ "login.repeat_password" | translate}}</mat-label>
<input type="password" matInput [(ngModel)]="credentials.repeatPassword" name="password2"
>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ "login.repeat_password" | translate}}</mat-label>
<input type="password" matInput [(ngModel)]="credentials.repeatPassword" name="password2"
[ngModelOptions]="{standalone: true}" required>
</mat-form-field>
<button mat-raised-button color="primary" [disabled]="!canCreate()"
(click)="register()">{{"create_account.create" | translate}}</button>
<button mat-raised-button color="primary" [disabled]="!canCreate()"
type="submit">{{"create_account.create" | translate}}</button>
</form>
</mat-tab>
</mat-tab-group>
</mat-card>

View File

@ -3,6 +3,7 @@ import {MessengerService} from "../messenger.service";
import {MessengerState} from "./messenger";
import {Subscription} from "rxjs";
import {MatSnackBar, MatSnackBarConfig} from "@angular/material";
import {TranslateService} from "@ngx-translate/core";
@Component({
selector: 'messenger-snack-bar',
@ -13,7 +14,10 @@ export class SnackBarComponent implements OnInit {
private subscription: Subscription;
constructor(private messengerService: MessengerService, private snackBar: MatSnackBar) {
constructor(
private messengerService: MessengerService,
private snackBar: MatSnackBar,
private translate: TranslateService) {
}
@ -23,9 +27,11 @@ export class SnackBarComponent implements OnInit {
if (state.hidden) {
this.snackBar.dismiss();
} else {
this.snackBar.open(state.message, "Close", <MatSnackBarConfig>{
duration: 10 * 1000,
})
this.translate.get("messenger.close")
.subscribe(t =>
this.snackBar.open(state.message, t, <MatSnackBarConfig>{
duration: 10 * 1000,
}))
}
});
}

View File

@ -1,3 +1,5 @@
interface Manager {
id: number;
username: string
website_admin: boolean;
}

View File

@ -0,0 +1,6 @@
export interface Worker {
id: number
alias: string
created: number
secret: string
}

View File

@ -56,6 +56,8 @@
<mat-card-actions>
<button mat-raised-button color="primary" *ngIf="project"
[routerLink]="'/project/' + project.id + '/update'">{{"project.update" | translate}}</button>
<button mat-raised-button color="primary" *ngIf="project"
[routerLink]="'/project/' + project.id + '/perms'">{{"project.perms" | translate}}</button>
</mat-card-actions>
</mat-card>
</div>

View File

@ -1,3 +1,7 @@
button {
margin-right: 15px;
}
mat-panel-title > mat-icon {
margin-right: 1em;
}

View File

@ -7,16 +7,22 @@
<mat-accordion>
<mat-expansion-panel *ngFor="let project of projects">
<mat-expansion-panel-header>
<mat-panel-title><span style="width: 3em">{{project.id}}</span>{{project.name}}
<mat-panel-title>
<mat-icon *ngIf="project.public">public</mat-icon>
<mat-icon *ngIf="!project.public">lock</mat-icon>
<span style="width: 3em">{{project.id}}</span>{{project.name}}
</mat-panel-title>
<mat-panel-description>{{project.motd}}</mat-panel-description>
</mat-expansion-panel-header>
<pre>{{project | json}}</pre>
<div>
<button mat-raised-button [routerLink]="'/project/' + project.id">
<button mat-raised-button color="primary" [routerLink]="'/project/' + project.id">
<mat-icon>timeline</mat-icon>{{"projects.dashboard" | translate}}</button>
<button mat-raised-button [routerLink]="'/project/' + project.id + '/update'">
<button mat-raised-button color="primary" [routerLink]="'/project/' + project.id + '/update'">
<mat-icon>build</mat-icon>{{"project.update" | translate}}</button>
<button mat-raised-button color="primary" [routerLink]="'/project/' + project.id + '/perms'">
<mat-icon>perm_identity</mat-icon>
{{"project.perms" | translate}}</button>
</div>
</mat-expansion-panel>
<span *ngIf="projects && projects.length == 0">

View File

@ -0,0 +1,15 @@
.unauthorized {
text-align: center;
color: #616161;
min-height: 3em;
margin-top: 2em !important;
}
.text-mono {
font-family: Hack, Courier, "Courier New", monospace;
color: #ff4081;
}
button {
margin-left: 15px;
}

View File

@ -0,0 +1,37 @@
<div class="container">
<mat-card class="mat-elevation-z8">
<button mat-button [title]="'perms.refresh' | translate" style="float:right"
(click)="refresh()">
<mat-icon>refresh</mat-icon>
</button>
<mat-card-header>
<mat-card-title>{{"perms.title" | translate}}</mat-card-title>
<mat-card-subtitle>{{"perms.subtitle" | translate}}</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<mat-list *ngIf="!unauthorized">
<mat-list-item *ngFor="let w of requests">
<mat-icon mat-list-icon>person_add</mat-icon>
<h4 mat-line>{{w.alias}}</h4>
<div mat-line>
Id=<span class="text-mono">{{w.id}}</span>, {{"perms.created" | translate}}
<span class="text-mono">{{moment.unix(w.created).format("YYYY-MM-DD HH:mm:ss UTC")}}</span>
</div>
<span style="flex: 1 1 auto;"></span>
<button mat-raised-button color="primary" [title]="'perms.grant' | translate">
<mat-icon>check</mat-icon>
</button>
<button mat-raised-button color="warn" [title]="'perms.reject' | translate">
<mat-icon>close</mat-icon>
</button>
</mat-list-item>
</mat-list>
<p *ngIf="unauthorized" class="unauthorized">
<mat-icon>block</mat-icon>
{{"perms.unauthorized" | translate}}
</p>
</mat-card-content>
</mat-card>
</div>

View File

@ -0,0 +1,60 @@
import {Worker} from "../models/worker"
import {Component, OnInit} from '@angular/core';
import {ApiService} from "../api.service";
import {Project} from "../models/project";
import {ActivatedRoute, Router} from "@angular/router";
import {MessengerService} from "../messenger.service";
import {TranslateService} from "@ngx-translate/core";
import * as moment from "moment"
@Component({
selector: 'app-project-perms',
templateUrl: './project-perms.component.html',
styleUrls: ['./project-perms.component.css']
})
export class ProjectPermsComponent implements OnInit {
constructor(private apiService: ApiService,
private route: ActivatedRoute,
private messengerService: MessengerService,
private translate: TranslateService,
private router: Router) {
}
project: Project;
private projectId: number;
requests: Worker[];
unauthorized: boolean = false;
moment = moment;
ngOnInit() {
this.route.params.subscribe(params => {
this.projectId = params["id"];
this.getProject();
this.getProjectRequests();
})
}
private getProject() {
this.apiService.getProject(this.projectId).subscribe(data => {
this.project = data["project"]
})
}
private getProjectRequests() {
this.apiService.getProjectAccessRequests(this.projectId).subscribe(
data => {
this.requests = data["requests"]
},
error => {
if (error && (error.status == 401 || error.status == 403)) {
this.unauthorized = true;
}
})
}
private refresh() {
this.getProjectRequests()
}
}

View File

@ -29,16 +29,7 @@ export class UpdateProjectComponent implements OnInit {
private getProject() {
this.apiService.getProject(this.projectId).subscribe(data => {
this.project = <Project>{
id: data["project"]["id"],
name: data["project"]["name"],
clone_url: data["project"]["clone_url"],
git_repo: data["project"]["git_repo"],
motd: data["project"]["motd"],
priority: data["project"]["priority"],
version: data["project"]["version"],
public: data["project"]["public"],
}
this.project = data["project"]
})
}

View File

@ -57,7 +57,8 @@
"create": "Create",
"git_repo": "Git repository name",
"motd": "Message of the day",
"update": "Edit"
"update": "Edit",
"perms": "Permissions"
},
"dashboard": {
"title": "Dashboard for",
@ -86,5 +87,18 @@
"workers": {
"title": "Completed tasks per worker",
"subtitle": "Real-time data for all projects"
},
"perms": {
"title": "Project permissions",
"subtitle": "Workers need to request access to work on private projects",
"unauthorized": "You need ROLE_MANAGE_ACCESS on this project to access this page",
"created": "Created on",
"grant": "Accept request",
"reject": "Deny request",
"refresh": "Refresh"
},
"messenger": {
"close": "Close",
"unauthorized": "Unauthorized"
}
}

View File

@ -58,7 +58,8 @@
"public": "Publique",
"create": "Créer",
"motd": "Message du jour",
"update": "Mettre à jour"
"update": "Mettre à jour",
"perms": "Permissions"
},
"dashboard": {
"title": "Tableau de bord pour ",
@ -88,6 +89,19 @@
"workers": {
"title": "Tâches complétés par worker",
"subtitle": "Données en temps réél pour tous les projets"
},
"perms": {
"title": "Permissions du projet",
"subtitle": "Les Workers doivent faire un requête d'acces pour pouvoir travailler sur les projets privés",
"unauthorized": "Vous devez avoir ROLE_GESTION_ACCES sur ce project pour accéder à cette page",
"created": "Créé le",
"grant": "Accepter la requête",
"reject": "Rejeter la requête",
"refresh": "Refraichir"
},
"messenger": {
"close": "Fermer",
"unauthorized": "Non autorisé"
}
}