Allow to manage passwords for a collective

This commit is contained in:
eikek 2021-09-30 10:35:38 +02:00
parent 3c93b63c8a
commit f74624485f
11 changed files with 190 additions and 9 deletions

View File

@ -19,6 +19,7 @@ services:
image: docspell/joex:latest
container_name: docspell-joex
command:
- -J-Xmx3G
- /opt/docspell.conf
restart: unless-stopped
env_file: ./.env

View File

@ -63,6 +63,12 @@ trait OCollective[F[_]] {
def findEnabledSource(sourceId: Ident): F[Option[RSource]]
def addPassword(collective: Ident, pw: Password): F[Unit]
def getPasswords(collective: Ident): F[List[RCollectivePassword]]
def removePassword(id: Ident): F[Unit]
def startLearnClassifier(collective: Ident): F[Unit]
def startEmptyTrash(args: EmptyTrashArgs): F[Unit]
@ -149,7 +155,7 @@ object OCollective {
private def updateLearnClassifierTask(coll: Ident, sett: Settings): F[Unit] =
for {
id <- Ident.randomId[F]
on = sett.classifier.map(_.enabled).getOrElse(false)
on = sett.classifier.exists(_.enabled)
timer = sett.classifier.map(_.schedule).getOrElse(CalEvent.unsafe(""))
args = LearnClassifierArgs(coll)
ut = UserTask(
@ -174,6 +180,18 @@ object OCollective {
_ <- joex.notifyAllNodes
} yield ()
def addPassword(collective: Ident, pw: Password): F[Unit] =
for {
cpass <- RCollectivePassword.createNew[F](collective, pw)
_ <- store.transact(RCollectivePassword.upsert(cpass))
} yield ()
def getPasswords(collective: Ident): F[List[RCollectivePassword]] =
store.transact(RCollectivePassword.findAll(collective))
def removePassword(id: Ident): F[Unit] =
store.transact(RCollectivePassword.deleteById(id)).map(_ => ())
def startLearnClassifier(collective: Ident): F[Unit] =
for {
id <- Ident.randomId[F]

View File

@ -5635,6 +5635,7 @@ components:
- integrationEnabled
- classifier
- emptyTrash
- passwords
properties:
language:
type: string
@ -5648,6 +5649,11 @@ components:
$ref: "#/components/schemas/ClassifierSetting"
emptyTrash:
$ref: "#/components/schemas/EmptyTrashSetting"
passwords:
type: array
items:
type: string
format: password
EmptyTrashSetting:
description: |

View File

@ -12,8 +12,7 @@ import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.backend.ops.OCollective
import docspell.common.EmptyTrashArgs
import docspell.common.ListType
import docspell.common._
import docspell.restapi.model._
import docspell.restserver.conv.Conversions
import docspell.restserver.http4s._
@ -62,7 +61,8 @@ object CollectiveRoutes {
settings.emptyTrash.schedule,
settings.emptyTrash.minAge
)
)
),
settings.passwords.map(Password.apply)
)
res <-
backend.collective
@ -89,7 +89,8 @@ object CollectiveRoutes {
EmptyTrashSetting(
trash.schedule,
trash.minAge
)
),
settDb.map(_.passwords).getOrElse(Nil).map(_.pass)
)
)
resp <- sett.toResponse()

View File

@ -0,0 +1,7 @@
CREATE TABLE "collective_password" (
"id" varchar(254) not null primary key,
"cid" varchar(254) not null,
"pass" varchar(254) not null,
"created" timestamp not null,
foreign key ("cid") references "collective"("cid") on delete cascade
)

View File

@ -0,0 +1,7 @@
CREATE TABLE `collective_password` (
`id` varchar(254) not null primary key,
`cid` varchar(254) not null,
`pass` varchar(254) not null,
`created` timestamp not null,
foreign key (`cid`) references `collective`(`cid`) on delete cascade
)

View File

@ -0,0 +1,7 @@
CREATE TABLE "collective_password" (
"id" varchar(254) not null primary key,
"cid" varchar(254) not null,
"pass" varchar(254) not null,
"created" timestamp not null,
foreign key ("cid") references "collective"("cid") on delete cascade
)

View File

@ -89,7 +89,8 @@ object RCollective {
case None =>
REmptyTrashSetting.delete(cid)
}
} yield n1 + n2 + n3
n4 <- RCollectivePassword.replaceAll(cid, settings.passwords)
} yield n1 + n2 + n3 + n4
// this hides categories that have been deleted in the meantime
// they are finally removed from the json array once the learn classifier task is run
@ -99,10 +100,12 @@ object RCollective {
prev <- OptionT.fromOption[ConnectionIO](sett.classifier)
cats <- OptionT.liftF(RTag.listCategories(coll))
next = prev.copy(categories = prev.categories.intersect(cats))
} yield sett.copy(classifier = Some(next))).value
pws <- OptionT.liftF(RCollectivePassword.findAll(coll))
} yield sett.copy(classifier = Some(next), passwords = pws.map(_.password))).value
private def getRawSettings(coll: Ident): ConnectionIO[Option[Settings]] = {
import RClassifierSetting.stringListMeta
val c = RCollective.as("c")
val cs = RClassifierSetting.as("cs")
val es = REmptyTrashSetting.as("es")
@ -116,7 +119,8 @@ object RCollective {
cs.categories.s,
cs.listType.s,
es.schedule.s,
es.minAge.s
es.minAge.s,
const(0) //dummy value to load Nil as list of passwords
),
from(c).leftJoin(cs, cs.cid === c.id).leftJoin(es, es.cid === c.id),
c.id === coll
@ -170,7 +174,11 @@ object RCollective {
language: Language,
integrationEnabled: Boolean,
classifier: Option[RClassifierSetting.Classifier],
emptyTrash: Option[REmptyTrashSetting.EmptyTrash]
emptyTrash: Option[REmptyTrashSetting.EmptyTrash],
passwords: List[Password]
)
implicit val passwordListMeta: Read[List[Password]] =
Read[Int].map(_ => Nil: List[Password])
}

View File

@ -0,0 +1,79 @@
package docspell.store.records
import cats.data.NonEmptyList
import docspell.common._
import docspell.store.qb._
import docspell.store.qb.DSL._
import doobie._
import doobie.implicits._
import cats.effect._
import cats.implicits._
final case class RCollectivePassword(
id: Ident,
cid: Ident,
password: Password,
created: Timestamp
) {}
object RCollectivePassword {
final case class Table(alias: Option[String]) extends TableDef {
val tableName: String = "collective_password"
val id = Column[Ident]("id", this)
val cid = Column[Ident]("cid", this)
val password = Column[Password]("pass", this)
val created = Column[Timestamp]("created", this)
val all: NonEmptyList[Column[_]] =
NonEmptyList.of(id, cid, password, created)
}
val T = Table(None)
def as(alias: String): Table =
Table(Some(alias))
def createNew[F[_]: Sync](cid: Ident, pw: Password): F[RCollectivePassword] =
for {
id <- Ident.randomId[F]
time <- Timestamp.current[F]
} yield RCollectivePassword(id, cid, pw, time)
def insert(v: RCollectivePassword): ConnectionIO[Int] =
DML.insert(
T,
T.all,
fr"${v.id}, ${v.cid},${v.password},${v.created}"
)
def upsert(v: RCollectivePassword): ConnectionIO[Int] =
for {
k <- deleteByPassword(v.cid, v.password)
n <- insert(v)
} yield n + k
def deleteById(id: Ident): ConnectionIO[Int] =
DML.delete(T, T.id === id)
def deleteByPassword(cid: Ident, pw: Password): ConnectionIO[Int] =
DML.delete(T, T.password === pw && T.cid === cid)
def findAll(cid: Ident): ConnectionIO[List[RCollectivePassword]] =
Select(select(T.all), from(T), T.cid === cid).build
.query[RCollectivePassword]
.to[List]
def replaceAll(cid: Ident, pws: List[Password]): ConnectionIO[Int] =
for {
k <- DML.delete(T, T.cid === cid)
pw <- pws.traverse(p => createNew[ConnectionIO](cid, p))
n <-
if (pws.isEmpty) 0.pure[ConnectionIO]
else
DML.insertMany(
T,
T.all,
pw.map(p => fr"${p.id},${p.cid},${p.password},${p.created}")
)
} yield k + n
}

View File

@ -22,6 +22,7 @@ import Comp.ClassifierSettingsForm
import Comp.Dropdown
import Comp.EmptyTrashForm
import Comp.MenuBar as MB
import Comp.StringListInput
import Data.DropdownStyle as DS
import Data.Flags exposing (Flags)
import Data.Language exposing (Language)
@ -30,6 +31,7 @@ import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onCheck, onClick, onInput)
import Http
import Markdown
import Messages.Comp.CollectiveSettingsForm exposing (Texts)
import Styles as S
@ -44,6 +46,8 @@ type alias Model =
, startClassifierResult : ClassifierResult
, emptyTrashModel : Comp.EmptyTrashForm.Model
, startEmptyTrashResult : EmptyTrashResult
, passwordModel : Comp.StringListInput.Model
, passwords : List String
}
@ -96,6 +100,8 @@ init flags settings =
, startClassifierResult = ClassifierResultInitial
, emptyTrashModel = em
, startEmptyTrashResult = EmptyTrashResultInitial
, passwordModel = Comp.StringListInput.init
, passwords = settings.passwords
}
, Cmd.batch [ Cmd.map ClassifierSettingMsg cc, Cmd.map EmptyTrashMsg ec ]
)
@ -114,6 +120,7 @@ getSettings model =
, integrationEnabled = model.intEnabled
, classifier = cls
, emptyTrash = trash
, passwords = model.passwords
}
)
(Comp.ClassifierSettingsForm.getSettings model.classifierModel)
@ -133,6 +140,7 @@ type Msg
| StartEmptyTrashTask
| StartClassifierResp (Result Http.Error BasicResult)
| StartEmptyTrashResp (Result Http.Error BasicResult)
| PasswordMsg Comp.StringListInput.Msg
update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe CollectiveSettings )
@ -285,6 +293,27 @@ update flags msg model =
, Nothing
)
PasswordMsg lm ->
let
( pm, action ) =
Comp.StringListInput.update lm model.passwordModel
pws =
case action of
Comp.StringListInput.AddAction pw ->
pw :: model.passwords
Comp.StringListInput.RemoveAction pw ->
List.filter (\e -> e /= pw) model.passwords
Comp.StringListInput.NoAction ->
model.passwords
in
( { model | passwordModel = pm, passwords = pws }
, Cmd.none
, Nothing
)
--- View2
@ -460,6 +489,18 @@ view2 flags texts settings model =
]
]
]
, div []
[ h2 [ class S.header2 ]
[ text texts.passwords
]
, div [ class "mb-4" ]
[ div [ class "opacity-50 text-sm" ]
[ Markdown.toHtml [] texts.passwordsInfo
]
, Html.map PasswordMsg
(Comp.StringListInput.view2 model.passwords model.passwordModel)
]
]
]

View File

@ -44,6 +44,8 @@ type alias Texts =
, fulltextReindexSubmitted : String
, fulltextReindexOkMissing : String
, emptyTrash : String
, passwords : String
, passwordsInfo : String
}
@ -77,6 +79,8 @@ gb =
, fulltextReindexOkMissing =
"Please type OK in the field if you really want to start re-indexing your data."
, emptyTrash = "Empty Trash"
, passwords = "Passwords"
, passwordsInfo = "These passwords are used when encrypted PDFs are being processed. Please note, that they are stored in the database as **plain text**!"
}
@ -110,4 +114,6 @@ de =
, fulltextReindexOkMissing =
"Bitte tippe OK in das Feld ein, wenn Du wirklich den Index neu erzeugen möchtest."
, emptyTrash = "Papierkorb löschen"
, passwords = "Passwörter"
, passwordsInfo = "Diese Passwörter werden zum Lesen von verschlüsselten PDFs verwendet. Diese Passwörter werden in der Datanbank **in Klartext** gespeichert!"
}